From b4b00ae3dd218c3412e0f721a645112cb1d9d4ca Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 12 Oct 2026 11:18:58 +0800 Subject: [PATCH 01/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- deploy/sql/海外区域统一增量-MySQL8.sql | 1 + .../service/DeliveryTransferService.java | 56 ++------ .../lastleg/relabel/dto/RelabelResponses.java | 14 +- .../relabel/mapper/RelabelItemMapper.java | 19 +++ .../pda/controller/PdaRelabelController.java | 7 + .../relabel/pda/dto/PdaRelabelRequests.java | 16 ++- .../relabel/pda/dto/PdaRelabelResponses.java | 28 +++- .../pda/service/PdaRelabelService.java | 48 ++++++- .../service/RelabelApplicationService.java | 50 ++++--- .../relabel/service/RelabelBindingGuard.java | 20 ++- .../service/ReturnRelabelPackingService.java | 136 ++++++++++++++++++ .../PdaReturnInventoryController.java | 5 + .../dto/ReturnInventoryRequests.java | 8 +- .../dto/ReturnInventoryResponses.java | 9 +- .../dto/ReturnOrderRequests.java | 4 +- .../entity/ReturnInventoryCarton.java | 2 + .../mapper/ReturnInventoryBalanceMapper.java | 4 +- .../mapper/ReturnInventoryCartonMapper.java | 45 +++++- .../ReturnRelabelCompletionLedgerMapper.java | 15 ++ .../service/ReturnInventoryService.java | 101 ++++++++++--- .../service/ReturnReceiptService.java | 38 ++--- .../service/ReturnRelabelProgress.java | 11 ++ .../src/main/resources/db/lastleg.sql | 25 ++++ .../openapi/overseas-business-v1.yaml | 30 ++-- .../openapi/overseas-pda-operations-v1.yaml | 79 +++++++++- .../resources/openapi/return-packing-v1.yaml | 15 +- .../service/DeliveryTransferServiceTest.java | 132 ++++++----------- .../service/ReturnOutboundClosureTest.java | 22 +-- .../ReturnOutboundPdaPersistenceTest.java | 23 --- .../pda/service/PdaRelabelServiceTest.java | 43 ++++-- .../RelabelApplicationServiceTest.java | 2 +- .../service/RelabelBindingGuardTest.java | 21 +++ .../RelabelReturnCartonCandidateTest.java | 14 +- .../ReturnRelabelPackingServiceTest.java | 107 ++++++++++++++ .../service/ReturnInventoryServiceTest.java | 78 ++++++++-- .../service/ReturnReceiptServiceTest.java | 26 ++-- scripts/README.md | 2 + scripts/db/incremental.sql | 25 ++++ ...-packing-boundary-incremental-20261010.sql | 24 ++++ 39 files changed, 990 insertions(+), 315 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java create mode 100644 scripts/db/return-package-packing-boundary-incremental-20261010.sql diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 8ec8d2b43..6cae76174 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2414,6 +2414,7 @@ CREATE TABLE ll_return_inventory_balance ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额'; CREATE TABLE ll_return_inventory_carton ( + label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签', new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛', id bigint NOT NULL COMMENT '退货库存箱ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index c037053ad..6f68db6d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -486,7 +486,6 @@ public class DeliveryTransferService { public List returnOutboundCandidates(String customerId, String warehouseId) { if (!StringUtils.hasText(warehouseId)) return List.of(); return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream() - .filter(carton -> !"PARCEL".equals(carton.getSourceUnitType())) .map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(), c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(), value(c.getTotalQuantity()), c.getStatus())).toList(); @@ -499,23 +498,16 @@ public class DeliveryTransferService { OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); String warehouseId = request.overseasWarehouseId().trim(); - boolean itemMode = request.items() != null; - boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()); - Map requestedItems = new LinkedHashMap<>(); - if (itemMode) { - if (request.items().isEmpty() || StringUtils.hasText(request.skuCode()) || request.quantity() != null || "PALLET".equals(request.unitType())) - throw conflict("散件明细出库不可同时提交统一SKU、数量或整托方式"); - for (var line : request.items()) { - if (line == null || !StringUtils.hasText(line.cartonMark()) || !StringUtils.hasText(line.skuCode()) || line.quantity() == null || line.quantity() <= 0 - || requestedItems.putIfAbsent(line.cartonMark().trim(), line) != null) throw conflict("散件出库明细不能为空、数量必须大于0且来源不可重复"); - } - } - if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0 - || "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库"); + // A return outbound is always a physical package/carton operation. SKU and + // quantity are selection fields for the packing workflow, never a direct + // outbound command. Partial package/carton quantities must be materialized + // into a new labelled carton before they can be picked. + boolean itemMode = request.items() != null && !request.items().isEmpty(); + boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()) || request.quantity() != null; + if (skuMode) + throw conflict("退货出库最小单位为包裹/箱;部分数量请先合箱并打印新箱标签后再出库"); List marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList(); if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复"); - if (itemMode && (marks.size() != requestedItems.size() || !requestedItems.keySet().containsAll(marks))) - throw conflict("散件出库明细与所选来源不一致"); LocalDateTime now = LocalDateTime.now(); List locked = new ArrayList<>(); for (String mark : marks.stream().sorted().toList()) { @@ -529,37 +521,18 @@ public class DeliveryTransferService { existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); for (ReturnInventoryCarton carton : locked) { - if (skuMode && !"PARCEL".equals(carton.getSourceUnitType())) - throw conflict("单SKU出库只允许散件库存;箱内库存请按箱或托盘出库"); - if ("PARCEL".equals(carton.getSourceUnitType()) && !skuMode) - throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库"); + if ("PACKING_RESERVED".equals(carton.getStatus())) + throw conflict("该包裹/箱正在合箱,请先完成或取消合箱"); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark()); var contents = returnCartonItems.lockCarton(carton.getId()); if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark()); - String selectedSku = itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()); - if (skuMode && (contents.size() != 1 - || !selectedSku.equals(contents.get(0).getSkuCode()))) - throw conflict("所选散件来源必须仅包含指定SKU"); - if (itemMode && requestedItems.get(carton.getCartonMark()).quantity() > value(contents.get(0).getQuantity())) - throw conflict("出库件数超过所选散件来源可用数量:" + carton.getCartonMark()); if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode())) throw conflict("整托出库必须存在托盘绑定"); } - Map skuQuantities = new LinkedHashMap<>(); - if (itemMode) requestedItems.forEach((mark, line) -> skuQuantities.put(mark, line.quantity())); - else if (StringUtils.hasText(request.skuCode())) { - long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum(); - long remaining = request.quantity() == null ? available : request.quantity(); - if (remaining > available) throw conflict("出库件数超过所选来源库存数量"); - for (ReturnInventoryCarton carton : locked) { - long selected = Math.min(remaining, value(carton.getTotalQuantity())); - if (selected > 0) skuQuantities.put(carton.getCartonMark(), selected); - remaining -= selected; - } - locked = locked.stream().filter(c -> skuQuantities.containsKey(c.getCartonMark())).toList(); - } + // No quantity allocation is performed here. The selected physical units + // are shipped whole; partials arrive only as newly-created merge cartons. if ("PALLET".equals(request.unitType())) { String pallet = locked.get(0).getPalletCode(); if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘"); @@ -580,9 +553,8 @@ public class DeliveryTransferService { OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); - // Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning. - item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()) : null); - item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark())); + // Packages and cartons are picked by their physical barcode. + item.setSkuCode(null); item.setReturnSkuQuantity(null); item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null); item.setCreatedAt(now); outboundPallets.insert(item); carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 9e8d44564..a3c2a424f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -38,7 +38,19 @@ public final class RelabelResponses { String destinationFbaWarehouseName, String skuCode, String productNameZh, long availablePackages, long version, List positions, String sourceType, Long returnCartonId, String cartonMark, - List skuItems, long relabelledPackages) {} + List skuItems, long relabelledPackages, String relabelStatus, + long relabelledQuantity, long relabelReservedQuantity) { + public InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo, String inboundCode, + String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, + String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, + long availablePackages, long version, List positions, String sourceType, + Long returnCartonId, String cartonMark, List skuItems, long relabelledPackages) { + this(inventoryBalanceId, inboundOrderId, inboundOrderNo, inboundCode, overseasWarehouseId, + overseasWarehouseCode, overseasWarehouseName, destinationFbaWarehouseCode, destinationFbaWarehouseName, + skuCode, productNameZh, availablePackages, version, positions, sourceType, returnCartonId, cartonMark, + skuItems, relabelledPackages, null, relabelledPackages, 0); + } + } public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 871f82830..3ef755bed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,6 +6,25 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + @org.apache.ibatis.annotations.Select("SELECT ri.* FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "WHERE ri.customer_id=#{customerId} AND ri.return_carton_id=#{cartonId} AND ri.application_id<>#{currentApplicationId} " + + "AND ra.status NOT IN ('CANCELLED','REJECTED') AND ra.customer_confirmed_at IS NOT NULL " + + "AND EXISTS(SELECT 1 FROM ll_relabel_pda_unit_fact f WHERE f.item_id=ri.id AND f.result='RELABELLED') " + + "AND NOT EXISTS(SELECT 1 FROM ll_relabel_item newer JOIN ll_relabel_application na ON na.id=newer.application_id " + + "WHERE newer.return_carton_id=ri.return_carton_id AND newer.application_id<>#{currentApplicationId} AND na.status NOT IN ('CANCELLED','REJECTED') " + + "AND (COALESCE(newer.source_carton_mark,newer.carton_mark)=COALESCE(ri.source_carton_mark,ri.carton_mark) " + + "OR COALESCE(newer.source_carton_mark,newer.carton_mark)=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId})) " + + "AND (na.created_at>ra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id))) ORDER BY ri.id") + java.util.List priorCompletedReturnItems(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("cartonId") long cartonId, + @org.apache.ibatis.annotations.Param("currentApplicationId") long currentApplicationId); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " + + "SET i.return_carton_id=#{targetId},i.carton_mark=#{targetMark} WHERE i.customer_id=#{customerId} " + + "AND i.return_carton_id=#{sourceId} AND a.status NOT IN ('CANCELLED','REJECTED') AND a.customer_confirmed_at IS NOT NULL") + int moveCompletedReturnCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("sourceId") long sourceId, + @org.apache.ibatis.annotations.Param("targetId") long targetId, + @org.apache.ibatis.annotations.Param("targetMark") String targetMark); @org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE") Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId, @org.apache.ibatis.annotations.Param("balanceId") Long balanceId); 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 0e4405b67..a15335082 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 @@ -86,6 +86,13 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/return-cartons/pack") + public ApiResponse packReturn(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) { + Identity identity = identity(user); + return ApiResponse.success(service.packReturn(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) { 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 a44f212f0..b38d7d556 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 @@ -17,7 +17,13 @@ public final class PdaRelabelRequests { public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex, @NotBlank @Size(max = 255) String oldSku, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 128) String cartonMark, + @Size(max = 128) String newSkuCode, @Positive Integer quantity) { + public SkuScan(Long itemId, int skuIndex, String oldSku, Integer expectedVersion, String idempotencyKey) { + this(itemId, skuIndex, oldSku, expectedVersion, idempotencyKey, null, null, null); + } + } public record CartonScan(@NotBlank @Size(max = 255) String cartonMark, @NotNull @Min(0) Integer expectedVersion, @@ -40,7 +46,13 @@ public final class PdaRelabelRequests { public record BulkSku(@NotNull @Positive Long itemId, @Positive int quantity, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 128) String cartonMark, + @NotBlank @Size(max = 128) String skuCode) { + public BulkSku(Long itemId, int quantity, Integer expectedVersion, String idempotencyKey) { + this(itemId, quantity, expectedVersion, idempotencyKey, null, null); + } + } public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode, @Positive int quantity, 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 02afc5ff0..0b246255f 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 @@ -20,7 +20,20 @@ public final class PdaRelabelResponses { String operatorName, String reviewerName, List completionPhotoFileIds, String completionRemark, String reviewDecision, String reviewRemark, LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, - List items, List unitFacts, List history) { } + List items, List unitFacts, List history, String deliveryMethod, + boolean requiresPalletBinding) { + public Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String contactName, + String contactPhone, String applicationStatus, String operationType, String taskStatus, int expectedPackages, + int relabelledPackages, int skippedPackages, int exceptionPackages, int version, String operatorName, + String reviewerName, List completionPhotoFileIds, String completionRemark, String reviewDecision, + String reviewRemark, LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List history) { + this(relabelNo, customerOrderNo, labelDeadline, contactName, contactPhone, applicationStatus, operationType, + taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, + operatorName, reviewerName, completionPhotoFileIds, completionRemark, reviewDecision, reviewRemark, + claimedAt, submittedAt, reviewedAt, items, unitFacts, history, null, false); + } + } public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode, String newSkuCode, String labelRequirement, @@ -29,7 +42,18 @@ public final class PdaRelabelResponses { List skuItems, int expectedPackages, int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages, - String destinationFbaWarehouseCode) { } + String destinationFbaWarehouseCode, Long returnCartonId, long returnQuantity) { + public Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl, + String cartonMark, String fbaCartonMark, String operationType, List skuItems, int expectedPackages, + int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages, + int skuProcessedPackages, String destinationFbaWarehouseCode) { + this(itemId, skuCode, productNameZh, shipmentNo, trackingCode, newSkuCode, labelRequirement, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, operationType, skuItems, + expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, cartonProcessedPackages, + skuProcessedPackages, destinationFbaWarehouseCode, null, 0); + } + } public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { } public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, 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 6e18f077a..9b1c186ba 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 @@ -52,6 +52,32 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { + @Resource private com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService returnPacking; + + @Transactional + public PdaRelabelResponses.Detail packReturn(String operatorKey, String operatorName, long warehouseId, + String relabelNo, com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) { + UnitCommand command = new UnitCommand(relabelNo, "RETURN_PACK", request); + var replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_PACK", request.idempotencyKey(), command); + if (replay != null) return replay; + var app = requireApplication(relabelNo, warehouseId, true); + var task = requireOwnedTask(app, operatorKey, true); + var selected = itemList(app).stream().filter(i -> i.getReturnCartonId() != null + && Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(operationType(i))).toList(); + if (selected.isEmpty() || selected.size() != itemList(app).size()) throw conflict("仅退货按件换标任务可使用此合箱操作"); + var facts = factList(task); + for (var item : selected) if (quantity(facts.stream().filter(f -> item.getId().equals(f.getItemId())).toList(), "RELABELLED") + != value(item.getNotifiedPackageCount())) throw conflict("请先完成每个来源包裹/箱的换标数量登记,再合箱"); + var sourceDeductions = returnPacking.deductions(selected); + var packed = returnPacking.pack(new com.baiyue.lastleg.pda.security.PdaWarehouseIdentity(operatorKey, operatorName, warehouseId), app, selected, request); + updateTask(task, task.getVersion()); + insertHistory(task, "RETURN_PACK", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("cartonMark", packed.cartonMark(), "quantity", packed.totalQuantity(), "label", packed.label(), + "round", mixedRound(task), "sourceCartonMarks", request.sourceCartonMarks(), "sourceDeductions", sourceDeductions)); + var result = detail(app, task); + saveOperation(task, operatorKey, "RETURN_PACK", request.idempotencyKey(), command, result); + return result; + } @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; public static final String AVAILABLE = "AVAILABLE"; public static final String PROCESSING = "PROCESSING"; @@ -230,11 +256,16 @@ public class PdaRelabelService { var task = requireOwnedTask(app, operatorKey, true); requireVersion(task, request.expectedVersion()); var item = requireItem(app, request.itemId()); + if (item.getReturnCartonId() != null && !Objects.equals(item.getCartonMark(), trim(request.cartonMark()))) + throw conflict("请扫描当前来源包裹/箱号后再扫描 SKU"); if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务"); if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱"); var skus = readSkuItems(item.getSkuRelabelItemsJson()); if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在"); var sku = skus.get(request.skuIndex()); + if (item.getReturnCartonId() != null && (!Objects.equals(sku.newSkuCode(), trim(request.newSkuCode())) + || !Objects.equals(sku.quantity(), request.quantity()))) + throw conflict("新 SKU 和件数必须与已批准的来源箱明细一致"); if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0) throw conflict("旧SKU或绑定的新标签、数量不正确"); if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId())) @@ -275,6 +306,9 @@ public class PdaRelabelService { requireVersion(task, request.expectedVersion()); RelabelItem item = requireItem(application, request.itemId()); if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务"); + if (!Objects.equals(item.getCartonMark(), trim(request.cartonMark())) + || !Objects.toString(item.getSkuCode(), "").equalsIgnoreCase(Objects.toString(trim(request.skuCode()), ""))) + throw conflict("请扫描当前来源包裹/箱号和对应 SKU,不能登记其他来源"); 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 剩余贴标数量"); @@ -309,6 +343,8 @@ public class PdaRelabelService { .filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim())) .toList(); if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU"); + if (skuItems.stream().map(RelabelItem::getCartonMark).distinct().count() != 1) + throw conflict("同 SKU 来自多个包裹/箱,请逐来源扫码登记,不能自动分配"); List existingFacts = factList(task); Map completedByItem = new HashMap<>(); for (RelabelItem item : skuItems) { @@ -574,6 +610,9 @@ public class PdaRelabelService { throw conflict("Relabel item already has all required package facts"); } String normalizedOldLabel = trim(oldLabel); + // A merged return carton can carry several independent SKU task items. + // Product-label facts are unique by item; old_label's global key is for physical carton-label scans. + if (item.getReturnCartonId() != null && "SKU_LABEL".equals(operationType(item))) normalizedOldLabel = null; if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) { throw conflict("Old label was already processed by another relabel unit"); } @@ -746,7 +785,8 @@ public class PdaRelabelService { 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()); + item.getDestinationFbaWarehouseCode(), item.getReturnCartonId(), + returnQuantity(item)); }).toList(); List factResponses = facts.stream().map(fact -> new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), @@ -768,7 +808,7 @@ public class PdaRelabelService { task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), - itemResponses, factResponses, historyResponses); + itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), "TRUCK".equals(application.getDeliveryMethod())); } private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { @@ -841,6 +881,10 @@ public class PdaRelabelService { List.class, PdaRelabelResponses.SkuItem.class)); } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); } } + private long returnQuantity(RelabelItem item) { + var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); + return carton == null || carton.getTotalQuantity() == null ? 0L : carton.getTotalQuantity(); + } private static String operationType(RelabelItem item) { return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL"; } 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 4ad45ee05..bbc868a6f 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 @@ -264,7 +264,7 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getProductNameZh(), available, value(balance.getVersion()), positions, "INBOUND", null, null, List.of(), 0); }).filter(candidate -> candidate.availablePackages() > 0).toList()); - for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { + for (ReturnInventoryCarton carton : returnCartons.findRelabelCandidates(customerId, operationWarehouseId.trim())) { String keyword = trim(inboundCode); if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getCartonMark(), keyword) @@ -272,22 +272,22 @@ public class RelabelApplicationService implements WalletResultHandler { List cartonSkus = returnCartonItems.listCarton(carton.getId()).stream() .filter(item -> value(item.getQuantity()) > 0) .map(item -> { - long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode()); - long remaining = value(item.getQuantity()) - used - value(item.getPackingReservedQuantity()); + long remaining = "AVAILABLE".equals(carton.getStatus()) + ? value(item.getQuantity()) - value(item.getPackingReservedQuantity()) : 0; return new RelabelResponses.SkuItem(item.getSkuCode(), null, Math.toIntExact(Math.max(remaining, 0)), null); - }).filter(item -> item.quantity() > 0).toList(); + }).toList(); if (cartonSkus.isEmpty()) continue; String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) .collect(Collectors.joining(",")); long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); - if (availablePackages <= 0) continue; - long relabelledPackages = carton.getTotalQuantity() == null ? 0 - : Math.max(0, carton.getTotalQuantity() - availablePackages); + long relabelledPackages = returnCartons.totalRelabelledQuantity(carton.getId()); + long reserved = returnCartons.relabelReservedQuantity(carton.getId()); result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(), - "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages)); + "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages, + com.baiyue.lastleg.returnreceipt.service.ReturnRelabelProgress.status(value(carton.getTotalQuantity()), relabelledPackages, reserved), relabelledPackages, reserved)); } return result; } @@ -370,6 +370,7 @@ public class RelabelApplicationService implements WalletResultHandler { Set selectedCartonMarks = new HashSet<>(); Map selectedReturnCartons = new LinkedHashMap<>(); Map selectedReturnPackages = new LinkedHashMap<>(); + Map> selectedReturnSkus = new LinkedHashMap<>(); for (RelabelRequests.Item item : request.items()) { validateOperation(item); if (item.notifiedPackageCount() > item.totalPackageCount()) { @@ -401,9 +402,8 @@ public class RelabelApplicationService implements WalletResultHandler { final long sourceReturnCartonId = carton.getId(); Map availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream() .collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT), - line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity()) - - returnCartons.relabelledQuantity(sourceReturnCartonId, line.getSkuCode())), Long::sum)); - Map requestedSku = new java.util.HashMap<>(); + line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity())), Long::sum)); + Map requestedSku = selectedReturnSkus.computeIfAbsent(carton.getId(), ignored -> new java.util.HashMap<>()); if (item.skuItems() != null) for (var skuItem : item.skuItems()) { long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum); if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L)) @@ -414,10 +414,9 @@ public class RelabelApplicationService implements WalletResultHandler { if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType()) && (item.totalPackageCount() != 1 || item.notifiedPackageCount() != 1)) throw conflict("退货箱唛换标按真实箱计数,每个来源箱只能填写一箱"); - long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode()); long requestedPackages = selectedReturnPackages.merge(carton.getId(), (long) item.notifiedPackageCount(), Math::addExact); - if (requestedPackages + previouslyRelabelled > value(carton.getTotalQuantity())) { + if (requestedPackages > value(carton.getTotalQuantity())) { throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足"); } sources.add(new SourceInventory(item, null, carton)); @@ -495,7 +494,8 @@ public class RelabelApplicationService implements WalletResultHandler { } } for (String returnNo : selectedReturnCartons.values().stream().map(ReturnInventoryCarton::getSourceReturnNo) - .filter(StringUtils::hasText).distinct().sorted().toList()) { + .filter(StringUtils::hasText).flatMap(value -> java.util.Arrays.stream(value.split(","))) + .map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList()) { ReturnOrder returnOrder = returnOrders.lockCustomerNo(customerId, returnNo); if (returnOrder != null) { returnOrder.setRelabelApplicationId(applicationId); @@ -618,11 +618,15 @@ public class RelabelApplicationService implements WalletResultHandler { } if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); + if (Set.of("REJECTED", "CANCELLED").contains(next) && itemList(application.getId()).stream() + .filter(item -> item.getReturnCartonId() != null) + .anyMatch(item -> returnCartons.totalRelabelledQuantity(item.getReturnCartonId()) > 0)) + throw conflict("该退货任务已有实际商品换标事实,不能取消并恢复为未换标库存;请按异常完工或退回重做处理"); 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 (!hasCartonFile && !hasSkuFile) throw conflict("退货贴标审核前必须上传箱唛总文件或 SKU 总文件,两类资料可分别上传"); } if ("PROCESSING".equals(next) && !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) { @@ -686,11 +690,10 @@ public class RelabelApplicationService implements WalletResultHandler { insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); } } - ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + List linkedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper() .eq(ReturnOrder::getRelabelApplicationId, application.getId()) - .eq(ReturnOrder::getCustomerId, customerId) - .last("LIMIT 1")); - if (returnOrder != null) { + .eq(ReturnOrder::getCustomerId, customerId)); + for (ReturnOrder returnOrder : linkedReturnOrders) { returnOrder.setRelabelStatus(next); returnOrder.setUpdatedAt(now); returnOrders.updateById(returnOrder); @@ -938,11 +941,10 @@ public class RelabelApplicationService implements WalletResultHandler { request.remark(), now); } } - ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + List completedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper() .eq(ReturnOrder::getRelabelApplicationId, application.getId()) - .eq(ReturnOrder::getCustomerId, customerId) - .last("LIMIT 1")); - if (returnOrder != null) { + .eq(ReturnOrder::getCustomerId, customerId)); + for (ReturnOrder returnOrder : completedReturnOrders) { returnOrder.setRelabelStatus("COMPLETED"); returnOrder.setUpdatedAt(now); returnOrders.updateById(returnOrder); @@ -961,6 +963,7 @@ public class RelabelApplicationService implements WalletResultHandler { carton.setStatus(StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_PENDING" : awaitingTruck ? "RELABEL_PENDING" : "AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + if (carton.getRelabelApplicationId() == null) returnCartons.clearRelabel(carton.getId()); } insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); @@ -1295,6 +1298,7 @@ public class RelabelApplicationService implements WalletResultHandler { && "RELABEL_PENDING".equals(carton.getStatus())) { carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null); carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); + returnCartons.clearRelabel(carton.getId()); } continue; } 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 88cb5602f..e3de83b25 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 @@ -22,12 +22,13 @@ public class RelabelBindingGuard { @Resource private PdaRelabelHistoryMapper histories; @Resource private InventoryPositionMapper positions; @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnLines; @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 -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") + .stream().filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") .contains(item.getOperationType())).toList(); if (cartons.isEmpty()) return; Map> bindings = new HashMap<>(); @@ -44,7 +45,7 @@ public class RelabelBindingGuard { } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } } for (RelabelItem item : cartons) { - if (item.getReturnCartonId() != null && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) { var carton = returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失"); if (!"TRUCK".equals(app.getDeliveryMethod())) continue; @@ -52,7 +53,20 @@ public class RelabelBindingGuard { throw conflict("退货箱尚未完成排车托盘绑定"); continue; } - if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + if (Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType()) && item.getReturnCartonId() != null) { + var sameCarton = cartons.stream().filter(i -> Objects.equals(i.getReturnCartonId(), item.getReturnCartonId())).toList(); + var stock = item.getReturnCartonId() == null ? List.of() : returnLines.lockCarton(item.getReturnCartonId()); + var selected = new HashMap(); + for (var i : sameCarton) { + if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) selected.merge(i.getSkuCode().toUpperCase(Locale.ROOT), (long)i.getNotifiedPackageCount(), Long::sum); + else try { + for (var sku : objectMapper.readTree(i.getSkuRelabelItemsJson())) selected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum); + } catch (Exception failure) { throw conflict("换标明细无法读取"); } + } + var actual = new HashMap(); + stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum)); + if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工"); + if (!"TRUCK".equals(app.getDeliveryMethod())) continue; 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())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java new file mode 100644 index 000000000..0d3d23925 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import java.util.*; + +/** Called inside the owned PDA task transaction; materializes only its completed pieces. */ +@Service +public class ReturnRelabelPackingService { + @Resource private ReturnInventoryService inventory; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private RelabelItemMapper items; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper cartonItems; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnRelabelCompletionLedgerMapper completionLedger; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper facts; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper histories; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper tasks; + @Resource private com.fasterxml.jackson.databind.ObjectMapper json; + + @org.springframework.transaction.annotation.Transactional + public ReturnInventoryResponses.MergeResult pack(PdaWarehouseIdentity identity, RelabelApplication app, + List selected, ReturnInventoryRequests.BoxMerge request) { + if (request.targetCartonMark() != null || selected.isEmpty()) throw conflict("换标合箱必须生成独立新箱"); + var marks = selected.stream().map(RelabelItem::getCartonMark).distinct().sorted().toList(); + if (!new HashSet<>(marks).equals(new HashSet<>(request.sourceCartonMarks()))) throw conflict("请扫描本次换标全部来源包裹/箱"); + if (selected.stream().map(RelabelItem::getDestinationFbaWarehouseCode).distinct().count() != 1) + throw conflict("不同目的仓不能合箱"); + var deductions = deductions(selected); + if (request.sourceDeductions() != null && !totals(deductions).equals(totals(request.sourceDeductions()))) + throw conflict("合箱数量必须等于本次已完成换标的明细数量"); + var inheritedTransfers = new ArrayList>(); + for (String mark : marks) { + var source = cartons.lockMark(mark); + if (source == null || !app.getCustomerId().equals(source.getCustomerId()) + || !app.getId().equals(source.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(source.getStatus())) + throw conflict("来源包裹/箱已变化,请刷新"); + var prior = items.priorCompletedReturnItems(app.getCustomerId(), source.getId(), app.getId()); + if (!prior.isEmpty()) { + var ids = prior.stream().map(i -> i.getId().toString()).toList(); + var raw = inheritedQuantities(prior); + var physical = new HashMap(); + cartonItems.lockCarton(source.getId()).forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact)); + var moved = new ArrayList>(); + for (var entry : totals(deductions).entrySet()) { + if (!mark.equals(entry.getKey().get(0))) continue; + String sku = entry.getKey().get(1).toUpperCase(Locale.ROOT); + long inherited = raw.getOrDefault(sku, 0L) - completionLedger.deductedQuantity(ids, sku); + long stock = physical.getOrDefault(sku, 0L); + if (inherited < 0 || inherited > stock) throw conflict("历史换标实物归属与来源库存不一致,请核对来源"); + if (inherited > 0 && inherited < stock && entry.getValue() < stock) + throw conflict("已换标与未换标货物来源不能自动猜测,请整箱隔离后重新换标"); + long transfer = Math.min(inherited, entry.getValue()); + if (transfer > 0) moved.add(Map.of("skuCode", sku, "quantity", transfer)); + } + if (!moved.isEmpty()) inheritedTransfers.add(Map.of("sourceItemIds", ids, "sourceCartonMark", mark, "items", moved)); + } + source.setStatus("AVAILABLE"); cartons.updateById(source); + } + var result = inventory.mergeBoxes(identity, new ReturnInventoryRequests.BoxMerge(marks, null, + "RELABEL-PACK:" + app.getId() + ":" + request.idempotencyKey(), deductions, + request.length(), request.width(), request.height(), request.weight())); + for (String mark : marks) { + var source = cartons.lockMark(mark); + cartons.clearRelabel(source.getId()); + } + var target = cartons.lockMark(result.cartonMark()); + target.setStatus("RELABEL_PENDING"); target.setRelabelApplicationId(app.getId()); + target.setRelabelNo(app.getRelabelNo()); target.setRelabelStatus("PROCESSING"); + target.setDestinationWarehouseCode(selected.get(0).getDestinationFbaWarehouseCode()); + cartons.updateById(target); + for (var item : selected) { + item.setReturnCartonId(target.getId()); item.setCartonMark(target.getCartonMark()); + items.updateById(item); + } + if (!inheritedTransfers.isEmpty()) { + var task = tasks.find(app.getCustomerId(), app.getId()); + for (var transfer : inheritedTransfers) { + var detail = new LinkedHashMap(transfer); + detail.put("targetCartonMark", result.cartonMark()); detail.put("targetApplicationId", app.getId().toString()); + var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + history.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(app.getId()); history.setCustomerId(app.getCustomerId()); + history.setAction("RETURN_COMPLETION_TRANSFER"); history.setFromStatus("PROCESSING"); history.setToStatus("PROCESSING"); + history.setOperatorKey(identity.operatorKey()); history.setOperatorName(identity.operatorName()); + try { history.setDetailJson(json.writeValueAsString(detail)); } + catch (java.io.IOException failure) { throw new IllegalStateException(failure); } + history.setCreatedAt(java.time.LocalDateTime.now()); histories.insert(history); + } + } + return result; + } + + private Map inheritedQuantities(List prior) { + var completed = facts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .in("item_id", prior.stream().map(RelabelItem::getId).toList()).eq("result", "RELABELLED")); + var result = new HashMap(); + for (var item : prior) { + long count = completed.stream().filter(f -> item.getId().equals(f.getItemId())).mapToLong(f -> f.getQuantity() == null ? 1 : f.getQuantity()).sum(); + if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) result.merge(item.getSkuCode().toUpperCase(Locale.ROOT), count, Math::addExact); + else for (var deduction : deductions(List.of(item))) + result.merge(deduction.skuCode().toUpperCase(Locale.ROOT), Math.multiplyExact(count, deduction.quantity()), Math::addExact); + } + return result; + } + + public List deductions(List selected) { + var deductions = new ArrayList(); + for (var i : selected) { + if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), i.getSkuCode(), i.getNotifiedPackageCount())); + else try { + for (var sku : json.readTree(i.getSkuRelabelItemsJson())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), sku.path("skuCode").asText(), sku.path("quantity").asLong())); + } catch (java.io.IOException failure) { throw conflict("换标 SKU 明细无法读取"); } + } + return deductions; + } + private Map, Long> totals(List lines) { + var result = new LinkedHashMap, Long>(); + for (var line : lines) { + if (line.quantity() <= 0) throw conflict("合箱扣减数量必须为正数"); + result.merge(List.of(line.cartonMark(), line.skuCode()), line.quantity(), Math::addExact); + } + return result; + } + + private RuntimeException conflict(String reason) { + return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, reason); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java index 689ad53da..80cde6628 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java @@ -24,6 +24,11 @@ public class PdaReturnInventoryController { @Resource private ReturnInventoryService service; @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountService counting; + @PostMapping("/cartons/{cartonMark}/confirm-label") + public ApiResponse confirmLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) { + return ApiResponse.success(service.confirmLabel(PdaWarehouseIdentity.require(user), cartonMark)); + } + @GetMapping("/counting/tasks") public ApiResponse> countingTasks( @AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Query query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java index e3f4d37d5..8a3158e39 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java @@ -45,9 +45,13 @@ public final class ReturnInventoryRequests { @NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 500) List<@NotBlank String> sourceCartonMarks) { + @Size(max = 500) List<@NotBlank String> sourceCartonMarks, + @Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions) { + public PackingPlanCreate(List returnNos, String mode, List boxes, String remark, String idempotencyKey, List sourceCartonMarks) { + this(returnNos, mode, boxes, remark, idempotencyKey, sourceCartonMarks, null); + } public PackingPlanCreate(List returnNos, String mode, List boxes, String remark, String idempotencyKey) { - this(returnNos, mode, boxes, remark, idempotencyKey, List.of()); + this(returnNos, mode, boxes, remark, idempotencyKey, List.of(), null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 9da92ed40..610a1d433 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -12,7 +12,8 @@ public final class ReturnInventoryResponses { public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status, int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, - LocalDateTime createdAt) { } + LocalDateTime createdAt, String relabelStatus, long relabelledQuantity, + long relabelReservedQuantity) { } public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode, String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity, @@ -23,7 +24,8 @@ public final class ReturnInventoryResponses { String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, LocalDateTime receivedAt, List items, - String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory) { } + String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory, + long relabelledQuantity, long relabelReservedQuantity) { } public record MergeHistory(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo, String skuCode, long quantity, LocalDateTime createdAt) { } public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) { @@ -57,7 +59,8 @@ public final class ReturnInventoryResponses { String sourceUnitType, String sourceBatchNo, String status, int skuTypes, long totalQuantity, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, - LocalDateTime createdAt, String locationCode, String palletCode, LocalDateTime putawayAt) { } + LocalDateTime createdAt, String locationCode, String palletCode, LocalDateTime putawayAt, + long relabelledQuantity, long relabelReservedQuantity) { } public record PlatformMovement(String customerId, String overseasWarehouseId, String skuCode, String productNameZh, String movementType, String referenceNo, long totalDelta, long availableDelta, LocalDateTime occurredAt) { } 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 f577a1e83..def6d9c3e 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 @@ -35,6 +35,8 @@ public final class ReturnOrderRequests { @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String cartonRemark) {} public record RelabelItem(@NotBlank @Size(max = 128) String skuCode, - @Positive long quantity) {} + @Positive long quantity, @Size(max = 128) String cartonMark) { + public RelabelItem(String skuCode, long quantity) { this(skuCode, quantity, null); } + } public record Review(@NotBlank String decision, @Size(max = 500) String remark) { } } 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 89b865f58..cf80d89a0 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 @@ -31,6 +31,8 @@ public class ReturnInventoryCarton { private LocalDateTime relabelCompletedAt; private String status; private String countStatus = "CONFIRMED"; + /** WAIT_LABEL until the operator scans the affixed warehouse label; null for historical units. */ + private String labelStatus; private LocalDateTime countedAt; private String countedBy; private Integer skuTypes; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java index 7dae31f47..e5cea5c03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java @@ -15,7 +15,9 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1") + // PARCEL is a labelled physical unit but is not counted as a box in + // carton_quantity. Piece shipment must therefore only guard quantities. + "AND total_quantity>=#{quantity} AND available_quantity>=#{quantity}") int deductOutboundPieces(@Param("id") long id, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("quantity") long quantity); String FREE_STOCK_COLUMNS = "b.id,b.customer_id,b.overseas_warehouse_id,b.sku_code,b.product_name_zh,b.total_quantity,b.carton_quantity,b.created_at,b.updated_at," + 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 12b4fdf3b..813ac52b3 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 @@ -10,6 +10,8 @@ import java.util.List; @Mapper public interface ReturnInventoryCartonMapper extends BaseMapper { + @Update("UPDATE ll_return_inventory_carton SET relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL WHERE id=#{id}") + int clearRelabel(@Param("id") long id); @Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} ORDER BY id FOR UPDATE") List lockReceipt(@Param("receiptId") long receiptId); @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' AND count_status='CONFIRMED' ORDER BY carton_mark") @@ -37,6 +39,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapperAND overseas_warehouse_id=#{warehouseId} " + "ORDER BY created_at DESC,id DESC LIMIT 500") List findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " + + "AND count_status='CONFIRMED' AND status IN ('AVAILABLE','RELABEL_PENDING','PACKING_RESERVED') ORDER BY created_at DESC,id DESC LIMIT 500") + List findRelabelCandidates(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1") ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark); @@ -57,12 +62,46 @@ public interface ReturnInventoryCartonMapper extends BaseMapperra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id)))))"; + String COMPLETION_DEDUCTION = "COALESCE((SELECT SUM(j.quantity) FROM ll_relabel_pda_history h " + + "JOIN JSON_TABLE(CASE WHEN h.action='RETURN_COMPLETION_TRANSFER' AND JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END,'$.items[*]' COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode',quantity BIGINT PATH '$.quantity')) j " + + "WHERE h.action='RETURN_COMPLETION_TRANSFER' AND EXISTS(SELECT 1 FROM ll_relabel_item ri " + "JOIN ll_relabel_application ra ON ra.id=ri.application_id " + - "WHERE ri.return_carton_id=#{cartonId} AND ri.sku_code=#{skuCode} " + - "AND ra.status NOT IN ('CANCELLED','REJECTED')") + "WHERE ri.return_carton_id=#{cartonId} AND JSON_CONTAINS(CASE WHEN JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END," + + "JSON_QUOTE(CAST(ri.id AS CHAR)),'$.sourceItemIds') AND ra.status NOT IN ('CANCELLED','REJECTED') " + CURRENT_RELABEL_ROUND + ")"; + @Select("SELECT COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity " + + "ELSE f.quantity * COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END, '$[*]' " + + "COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode', quantity BIGINT PATH '$.quantity')) j WHERE UPPER(j.sku_code)=UPPER(#{skuCode})),0) END),0) - " + + COMPLETION_DEDUCTION + " AND UPPER(j.sku_code)=UPPER(#{skuCode})),0) FROM ll_relabel_item ri " + + "JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "JOIN ll_relabel_pda_unit_fact f ON f.item_id=ri.id AND f.result='RELABELLED' " + + "WHERE ri.return_carton_id=#{cartonId} AND (ri.operation_type<>'RETURN_BULK_SKU_LABEL' OR UPPER(ri.sku_code)=UPPER(#{skuCode})) " + + "AND ra.status NOT IN ('CANCELLED','REJECTED')" + CURRENT_RELABEL_ROUND) long relabelledQuantity(@Param("cartonId") long cartonId, @Param("skuCode") String skuCode); + String RELABEL_ITEM_QUANTITY = "CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN ri.notified_package_count ELSE " + + "COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END, '$[*]' " + + "COLUMNS(quantity BIGINT PATH '$.quantity')) j),0) END"; + @Select("SELECT COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity ELSE f.quantity * " + + "COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END,'$[*]' COLUMNS(quantity BIGINT PATH '$.quantity')) j),0) END),0) - " + + COMPLETION_DEDUCTION + "),0) " + + "FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "JOIN ll_relabel_pda_unit_fact f ON f.item_id=ri.id AND f.result='RELABELLED' " + + "WHERE ri.return_carton_id=#{cartonId} AND ra.status NOT IN ('CANCELLED','REJECTED')" + CURRENT_RELABEL_ROUND) + long totalRelabelledQuantity(@Param("cartonId") long cartonId); + @Select("SELECT COALESCE(SUM(GREATEST(0,(" + RELABEL_ITEM_QUANTITY + ") - " + + "COALESCE((SELECT SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity ELSE f.quantity * (" + + RELABEL_ITEM_QUANTITY + ") END) FROM ll_relabel_pda_unit_fact f WHERE f.item_id=ri.id AND f.result='RELABELLED'),0))),0) " + + "FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "WHERE ri.return_carton_id=#{cartonId} AND ra.status NOT IN ('CANCELLED','REJECTED','COMPLETED')") + long relabelReservedQuantity(@Param("cartonId") long cartonId); + @Update("UPDATE ll_return_inventory_carton SET total_quantity=#{remaining},sku_types=#{skuTypes},status='AVAILABLE'," + "relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL,updated_at=NOW(3) " + "WHERE id=#{id} AND customer_id=#{customerId} AND relabel_application_id=#{applicationId} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java new file mode 100644 index 000000000..3b171480d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** Immutable PDA history records exact completed physical pieces moved out of a historical item group. */ +@Mapper +public interface ReturnRelabelCompletionLedgerMapper { + @Select("") + long deductedQuantity(@Param("itemIds") List itemIds, @Param("skuCode") String skuCode); +} 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 e20cfe196..94d7adf43 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 @@ -56,6 +56,7 @@ public class ReturnInventoryService { public static final String MERGED = "MERGED"; @Resource private ReturnInventoryCartonMapper cartons; + @Resource private com.baiyue.lastleg.relabel.mapper.RelabelItemMapper relabelItems; @Resource private ReturnInventoryCartonItemMapper cartonItems; @Resource private ReturnInventoryBalanceMapper balances; @Resource private ReturnInventoryOperationMapper operations; @@ -129,13 +130,27 @@ public class ReturnInventoryService { if (!List.of("BOX", "SKU").contains(mode)) throw badRequest("合箱方式只能选择 BOX 或 SKU"); List marks = request.sourceCartonMarks() == null ? List.of() : request.sourceCartonMarks().stream().distinct().sorted().toList(); if ("BOX".equals(mode) && marks.isEmpty()) throw badRequest("按箱合箱必须选择来源箱唛"); - if ("SKU".equals(mode) && !marks.isEmpty()) throw badRequest("按 SKU 合箱不能同时选择来源箱"); + // SKU mode is the partial-packing mode. It may explicitly name one or + // more physical packages/boxes; the quantities in `boxes` are then + // deducted from those sources and materialized as a new labelled carton. Map requested = planQuantities(request.boxes()); + List exact = request.sourceDeductions(); + if (exact != null) { + if (!"SKU".equals(mode) || exact.isEmpty() || marks.isEmpty()) throw badRequest("部分合箱必须指定来源包裹/箱及数量"); + var seen = new java.util.HashSet(); + for (var line : exact) if (!marks.contains(line.cartonMark()) || line.quantity() <= 0 + || !seen.add(line.cartonMark() + ":" + line.skuCode())) throw badRequest("部分合箱来源重复或不属于所选包裹/箱"); + Map totals = new LinkedHashMap<>(); + exact.forEach(line -> totals.merge(line.skuCode(), line.quantity(), Math::addExact)); + if (!totals.equals(requested)) throw conflict("来源扣除数量必须与目标箱数量一致"); + } List allocation = new ArrayList<>(); List sources = "BOX".equals(mode) ? marks.stream().map(cartons::lockMark).toList() - : packingSourceCartons(selected).stream().filter(c -> "PARCEL".equals(c.getSourceUnitType())) - .sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList(); + : (marks.isEmpty() + ? packingSourceCartons(selected).stream() + .sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList() + : marks.stream().map(cartons::lockMark).toList()); Map remaining = new LinkedHashMap<>(requested); for (ReturnInventoryCarton carton : sources) { if (carton == null || !first.getCustomerId().equals(carton.getCustomerId()) @@ -143,10 +158,12 @@ public class ReturnInventoryService { || !AVAILABLE.equals(carton.getStatus()) || !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用"); ReturnCartonCountGuard.requireConfirmed(carton); - if ("BOX".equals(mode) && "PARCEL".equals(carton.getSourceUnitType())) throw badRequest("包裹散 SKU 不能作为整箱选择"); for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) { long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())); - long take = "BOX".equals(mode) ? free : Math.min(free, value(remaining.get(item.getSkuCode()))); + if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源包裹/箱已有合箱预占"); + long take = "BOX".equals(mode) ? free : exact == null ? Math.min(free, value(remaining.get(item.getSkuCode()))) + : exact.stream().filter(line -> carton.getCartonMark().equals(line.cartonMark()) && item.getSkuCode().equals(line.skuCode())).mapToLong(ReturnInventoryRequests.SourceDeduction::quantity).sum(); + if (take > free) throw conflict("来源包裹/箱扣除数量超过可用库存"); if ("BOX".equals(mode) && value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已有合箱预占"); if (take <= 0) continue; if ("SKU".equals(mode)) remaining.put(item.getSkuCode(), remaining.get(item.getSkuCode()) - take); @@ -154,7 +171,9 @@ public class ReturnInventoryService { cartonItems.updateById(item); allocation.add(new SourceItem(carton.getCartonMark(), item.getSkuCode(), take)); } - if ("BOX".equals(mode)) { carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton); } + if (allocation.stream().anyMatch(line -> line.cartonMark().equals(carton.getCartonMark()))) { + carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton); + } } if (allocation.isEmpty() || ("SKU".equals(mode) && remaining.values().stream().anyMatch(q -> q > 0))) throw conflict("来源库存不足,无法锁定合箱数量"); Map reserved = sourceQuantities(allocation); @@ -282,6 +301,8 @@ public class ReturnInventoryService { return new ReturnInventoryResponses.PackingPlanCompletion(plan.getPlanNo(), read(plan.getResultLabelsJson(), new TypeReference>() { })); } if (!"READY".equals(plan.getStatus())) throw conflict("合箱计划尚未审核或已取消"); + if ("SKU".equals(plan.getMode()) && request.boxes().stream().anyMatch(box -> text(box.targetCartonMark()) != null)) + throw conflict("部分包裹/箱必须合箱生成独立新箱"); if ("BOX".equals(plan.getMode()) && request.boxes().size() != 1) throw badRequest("箱与箱合并必须扫描一个已有目标箱,不支持拆分来源箱"); List allocation = read(plan.getSourceItemsJson(), new TypeReference<>() { }); if (allocation.isEmpty()) throw conflict("合箱计划缺少来源锁定明细,请重新创建"); @@ -289,7 +310,7 @@ public class ReturnInventoryService { ? text(request.boxes().get(0).targetCartonMark()) : null; List sourceAllocation = plannedTarget == null ? allocation : allocation.stream() .filter(source -> !plannedTarget.equals(source.cartonMark())).toList(); - if ("BOX".equals(plan.getMode()) && request.sourceDeductions() != null) { + if (request.sourceDeductions() != null) { Map seen = new LinkedHashMap<>(); List selected = new ArrayList<>(); for (var deduction : request.sourceDeductions()) { @@ -299,8 +320,13 @@ public class ReturnInventoryService { selected.add(new SourceItem(deduction.cartonMark(), deduction.skuCode(), deduction.quantity())); } sourceAllocation = selected; + if ("SKU".equals(plan.getMode()) && !new java.util.HashSet<>(sourceAllocation).equals(new java.util.HashSet<>(allocation))) + throw conflict("部分合箱实际来源与计划锁定来源数量不一致"); } final List selectedAllocation = sourceAllocation; + if (plannedTarget != null && allocation.stream().anyMatch(source -> !plannedTarget.equals(source.cartonMark()) + && selectedAllocation.stream().filter(s -> s.cartonMark().equals(source.cartonMark()) && s.skuCode().equals(source.skuCode())).mapToLong(SourceItem::quantity).sum() != source.quantity())) + throw conflict("部分包裹/箱必须合箱生成独立新箱,不能并入已有箱"); if (!sourceQuantities(sourceAllocation).equals(planQuantities(request.boxes()))) throw conflict("来源箱 SKU 数量合计必须与计划锁定数量完全一致"); List returnNos = read(plan.getReturnNosJson(), new TypeReference<>() { }); Map> limits = new LinkedHashMap<>(); @@ -464,8 +490,8 @@ public class ReturnInventoryService { required(lines.get(0).skuCode(), "SKU 不能为空"); String cartonMark = text(requestedMark); long cartonId = IdWorker.getId(); - // A parcel row tracks loose SKU provenance; it is never a physical carton or printable box label. - if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "SP" + hash(List.of(receipt.getId(), lines.get(0).skuCode())).substring(0, 24).toUpperCase(java.util.Locale.ROOT); + // One durable barcode identifies a physical parcel, irrespective of SKU count. + if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP" + hash(List.of(receipt.getId())).substring(0, 24).toUpperCase(java.util.Locale.ROOT); else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); LocalDateTime now = LocalDateTime.now(); @@ -476,10 +502,11 @@ public class ReturnInventoryService { carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark); carton.setPalletCode(text(receipt.getPalletCode())); carton.setSourceUnitType(receipt.getUnitType()); + carton.setLabelStatus("PALLET".equals(receipt.getUnitType()) ? null : "WAIT_LABEL"); carton.setCountStatus("PALLET".equals(receipt.getUnitType()) ? "PENDING" : "CONFIRMED"); carton.setSourceUnitCode(normalizedUnitCode); // Pallet measurements describe the carrier, never each child carton. - if ("BOX".equals(receipt.getUnitType())) { + if ("BOX".equals(receipt.getUnitType()) || "PARCEL".equals(receipt.getUnitType())) { carton.setLength(receipt.getLength()); carton.setWidth(receipt.getWidth()); carton.setHeight(receipt.getHeight()); carton.setWeight(receipt.getWeight()); } carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now); @@ -492,7 +519,7 @@ public class ReturnInventoryService { balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(), text(line.productNameZh()), line.quantity(), line.quantity(), "PARCEL".equals(receipt.getUnitType()) ? 0 : 1); } - if ("BOX".equals(receipt.getUnitType())) { + if ("BOX".equals(receipt.getUnitType()) || "PARCEL".equals(receipt.getUnitType())) { var contents = lines.stream().map(line -> new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku(line.skuCode(), line.productNameZh(), line.quantity())).toList(); var snapshot = new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task(receipt.getReceiptNo(), order.getReturnNo(), receipt.getPalletCode(), "CONFIRMED", contents, List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box(cartonMark, contents))); @@ -551,7 +578,8 @@ public class ReturnInventoryService { .map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), - carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList(); + carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), + relabelProgressStatus(carton), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); } @@ -594,8 +622,9 @@ public class ReturnInventoryService { List records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark, returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton( carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(), - displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(), - carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt())).toList(); + displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getLength(), + carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), + cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); } @@ -652,10 +681,20 @@ public class ReturnInventoryService { return label(carton); } + @Transactional + public ReturnInventoryResponses.Label confirmLabel(PdaWarehouseIdentity identity, String cartonMark) { + ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "请扫描已贴好的包裹/箱标签")); + if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该包裹/箱"); + if (!AVAILABLE.equals(carton.getStatus())) throw conflict("已占用或已拆空的包裹/箱不能确认贴标"); + if (!"CONFIRMED".equals(carton.getLabelStatus())) { + carton.setLabelStatus("CONFIRMED"); carton.setUpdatedAt(LocalDateTime.now()); cartons.updateById(carton); + } + return label(carton); + } + /** Print only persisted physical cartons; a parcel's SKU rows are not cartons. */ @Transactional(readOnly = true) public List receiptContents(ReturnReceipt receipt) { - if ("PARCEL".equals(receipt.getUnitType())) return List.of(); var snapshot = operations.receiptContents(receipt.getCustomerId(), receipt.getId()); if (snapshot != null) { var task = read(snapshot.getResultJson(), new TypeReference() { }); @@ -673,7 +712,6 @@ public class ReturnInventoryService { List lines) { List existing = cartons.listReceipt(receipt.getId()).stream() .filter(carton -> !"MERGED".equals(carton.getStatus())).toList(); - if ("PARCEL".equals(receipt.getUnitType())) return List.of(); long expected = "BOX".equals(receipt.getUnitType()) ? Math.max(1, value(receipt.getTotalBoxCount())) : lines.stream().mapToLong(line -> Math.max(0, line.boxCount())).sum(); if (expected <= 0 || existing.size() >= expected) { @@ -831,7 +869,7 @@ public class ReturnInventoryService { public ReturnInventoryResponses.CartonDetail scanCarton(PdaWarehouseIdentity identity, String mark) { ReturnInventoryCarton carton = cartons.findMark(required(mark, "箱唛不能为空")); if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱"); - if (!AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())) throw conflict("该箱不是可用实物箱"); + if (!AVAILABLE.equals(carton.getStatus())) throw conflict("该包裹/箱不是可用实物单元"); ReturnCartonCountGuard.requireConfirmed(carton); return cartonDetail(carton); } @@ -851,7 +889,7 @@ public class ReturnInventoryService { for (String mark : request.sourceCartonMarks().stream().distinct().sorted().toList()) { if (mark.equals(request.targetCartonMark())) throw badRequest("来源箱不能与目标箱相同"); ReturnInventoryCarton carton = cartons.lockMark(mark); - if (carton == null || !AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType()) + if (carton == null || !AVAILABLE.equals(carton.getStatus()) || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw conflict("来源箱不存在或不可合并"); if (customer != null && !customer.equals(carton.getCustomerId())) throw conflict("不同客户的箱不能合并"); ReturnCartonCountGuard.requireConfirmed(carton); @@ -868,6 +906,10 @@ public class ReturnInventoryService { } if(request.sourceDeductions() != null && request.sourceDeductions().stream().anyMatch(d -> !limits.containsKey(d.cartonMark()) || !limits.get(d.cartonMark()).containsKey(d.skuCode()))) throw badRequest("扣除明细不属于已扫描来源箱"); String targetMark = text(request.targetCartonMark()); + if (targetMark != null && request.sourceCartonMarks().stream().anyMatch(mark -> + cartonItems.lockCarton(cartons.lockMark(mark).getId()).stream().anyMatch(item -> + value(limits.get(mark).get(item.getSkuCode())) != value(item.getQuantity())))) + throw conflict("部分包裹/箱必须合箱生成独立新箱,不能并入已有箱"); if(targetMark == null && (request.length()==null || request.width()==null || request.height()==null || request.weight()==null)) throw badRequest("新增目标箱必须填写箱规"); var planItems=quantities.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(),e.getValue())).toList(); String newMark=targetMark==null?packingMarks(List.of(new ReturnInventoryRequests.PlanBox(planItems,request.length(),request.width(),request.height(),request.weight()))).get(0):null; @@ -956,7 +998,7 @@ public class ReturnInventoryService { Map requested = new LinkedHashMap<>(); Map productNames = new LinkedHashMap<>(); for (ReturnInventoryRequests.ValidItem item : request.items()) { - String sku = required(item.skuCode(), "SKU 不能为空").toUpperCase(java.util.Locale.ROOT); + String sku = required(item.skuCode(), "SKU 不能为空"); if (requested.put(sku, item.quantity()) != null) throw badRequest("合箱 SKU 不能重复"); } Map remaining = new LinkedHashMap<>(requested); @@ -965,13 +1007,21 @@ public class ReturnInventoryService { LinkedHashSet consumedReturnNos = new LinkedHashSet<>(); Map> attachedByOrder = new LinkedHashMap<>(); List transfers = new ArrayList<>(); + java.util.Set completedRelabelSources = new java.util.HashSet<>(); long total = 0; for (ReturnInventoryCarton source : sources) { // Read committed quantities under the source lock, never a stale MVCC snapshot. List sourceItems = cartonItems.lockCarton(source.getId()); + boolean hasCompletedRelabel = sourceItems.stream().anyMatch(i -> cartons.relabelledQuantity(source.getId(), i.getSkuCode()) > 0); + if (hasCompletedRelabel) completedRelabelSources.add(source.getId()); + if (hasCompletedRelabel && source.getRelabelApplicationId() == null && sourceItems.stream().anyMatch(i -> { + long free = Math.max(0, value(i.getQuantity()) - value(i.getPackingReservedQuantity())); + long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(i.getSkuCode()))); + return Math.min(value(remaining.get(i.getSkuCode())), allowed) != value(i.getQuantity()); + })) throw conflict("已换标与未换标货物不能自动分配来源,请先在换标任务内隔离为独立新箱,或整箱合并"); long sourceTotal = 0; int sourceTypes = 0; for (ReturnInventoryCartonItem item : sourceItems) { - String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT); + String sku = item.getSkuCode(); long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())); long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(sku))); long take = Math.min(value(remaining.get(sku)), allowed); @@ -1016,6 +1066,8 @@ public class ReturnInventoryService { } target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE); if (existingTarget == null) { + target.setCountStatus("CONFIRMED"); + target.setLabelStatus("WAIT_LABEL"); target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target); } @@ -1037,6 +1089,8 @@ public class ReturnInventoryService { } for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(), transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now); + for (ReturnInventoryCarton source : sources) if (completedRelabelSources.contains(source.getId()) && MERGED.equals(source.getStatus()) && source.getRelabelApplicationId() == null) + relabelItems.moveCompletedReturnCarton(customerId, source.getId(), targetId, target.getCartonMark()); var result = new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), value(target.getTotalQuantity()), label(target)); operation.setResultCartonId(targetId); operation.setResultJson(json(result)); operation.setUpdatedAt(now); operations.updateById(operation); return result; @@ -1111,7 +1165,12 @@ public class ReturnInventoryService { "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus()) ? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList(); return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), - displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark())); + displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()), + cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId())); + } + + private String relabelProgressStatus(ReturnInventoryCarton carton) { + return ReturnRelabelProgress.status(value(carton.getTotalQuantity()), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId())); } private static String printableHtml(ReturnInventoryCarton carton) { 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 72dd83856..415ccb48f 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 @@ -278,7 +278,7 @@ public class ReturnReceiptService { .toList(); long boxes = value(receipt.getTotalBoxCount()); if (boxes == 0) boxes = lines.stream().mapToLong(ReturnOrderResponses.ReceiptItem::boxCount).sum(); - String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark()); + String cartonMark = text(receipt.getCartonMark()); List labelLines = lines.stream() .map(line -> new ReturnInventoryService.ReturnOrderLine(line.skuCode(), line.productNameZh(), line.quantity(), line.boxCount())) .toList(); @@ -382,13 +382,12 @@ public class ReturnReceiptService { if (order == null) throw notFound("退货单不存在"); if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); List result = new ArrayList<>(); - for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { - for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { + for (ReturnInventoryCarton carton : cartons.findAvailable(customerId, order.getOverseasWarehouseId())) { + if (!inventory.matchesReturnSource(carton, order.getReturnNo())) continue; if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || !"CONFIRMED".equals(carton.getCountStatus()) || carton.getRelabelApplicationId() != null) continue; List items = cartonItems.listCarton(carton.getId()).stream() .map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList(); result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus())); - } } return result; } @@ -419,9 +418,9 @@ public class ReturnReceiptService { long totalQuantity = 0; for (ReturnOrderRequests.RelabelItem requested : request.items()) { String sku = norm(requested.skuCode()); - if (!seen.add(sku)) throw badRequest("同一贴标申请不能重复选择 SKU: " + requested.skuCode()); + if (!seen.add(sku + ":" + text(requested.cartonMark()))) throw badRequest("同一包裹/箱 SKU 不能重复选择: " + requested.skuCode()); ReturnOrderItem received = receivedBySku.get(sku); - if (received == null || requested.quantity() > value(received.getReceivedQuantity())) { + if (text(requested.cartonMark()) == null && (received == null || requested.quantity() > value(received.getReceivedQuantity()))) { throw conflict("SKU " + requested.skuCode() + " 的贴标数量超过当前退货单实收数量"); } totalQuantity = Math.addExact(totalQuantity, requested.quantity()); @@ -460,27 +459,35 @@ public class ReturnReceiptService { 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<>(); + Map, Long> allocatedBySourceSku = 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; + if (!inventory.matchesReturnSource(candidate, order.getReturnNo())) continue; + if (text(requested.cartonMark()) != null && !requested.cartonMark().equals(candidate.getCartonMark())) continue; long skuQuantity = cartonItems.listCarton(candidate.getId()).stream() .filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode()))) .mapToLong(line -> value(line.getQuantity())).sum(); - skuQuantity -= cartons.relabelledQuantity(candidate.getId(), norm(requested.skuCode())); 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("退货箱已被其他换标申请占用,请刷新后重试"); if (cartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划"); + skuQuantity = cartonItems.lockCarton(carton.getId()).stream() + .filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode()))) + .mapToLong(line -> value(line.getQuantity())).sum(); + var sourceSku = List.of(carton.getCartonMark(), norm(requested.skuCode())); + skuQuantity -= allocatedBySourceSku.getOrDefault(sourceSku, 0L); + if (skuQuantity <= 0) continue; long allocated = Math.min(remaining, skuQuantity); + allocatedBySourceSku.merge(sourceSku, allocated, Math::addExact); ReturnCartonCountGuard.requireConfirmed(carton); 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.setSkuCode(requested.skuCode()); + item.setProductNameZh(source == null || text(source.getProductNameZh()) == null ? requested.skuCode() : 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()); @@ -653,13 +660,12 @@ public class ReturnReceiptService { if ("PALLET".equals(unitType)) { inventoryCartons = inventory.createReceiptCartons(order, receipt, new ArrayList<>(inventoryLines.values()), boxCountBySku); } else if ("PARCEL".equals(unitType)) { - inventoryCartons = new ArrayList<>(); - for (var line : inventoryLines.values()) - inventoryCartons.add(inventory.createReceiptCarton(order, receipt, null, List.of(line))); + // One receipt represents one physical parcel, including all its SKUs. + inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, null, new ArrayList<>(inventoryLines.values()))); } else { inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, unitCode, new ArrayList<>(inventoryLines.values()))); } - receipt.setCartonMark("PARCEL".equals(unitType) ? null : inventoryCartons.get(0).getCartonMark()); + receipt.setCartonMark(inventoryCartons.get(0).getCartonMark()); receipts.updateById(receipt); syncTailCargo(order, receipt, allocations, now); List finalItems = orderItems.listOrder(order.getId()); @@ -802,10 +808,10 @@ public class ReturnReceiptService { return confirmed(order, receipt, COMPLETED.equals(order.getStatus())); } private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) { - String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark()); + String cartonMark = text(receipt.getCartonMark()); String printableHtml = null; List labels = new java.util.ArrayList<>(); - if (receipt.getId() != null && !"PARCEL".equals(receipt.getUnitType())) { + if (receipt.getId() != null) { for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { var label = inventory.label(order.getCustomerId(), carton.getCartonMark()); if (label != null) labels.add(new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label.printableHtml())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java new file mode 100644 index 000000000..28fb48e07 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.returnreceipt.service; + +/** Physical piece progress is independent of warehouse identification-label confirmation. */ +public final class ReturnRelabelProgress { + private ReturnRelabelProgress() { } + public static String status(long total, long completed, long reserved) { + if (total > 0 && completed >= total) return "COMPLETED"; + if (completed > 0) return "PARTIALLY_COMPLETED"; + return reserved > 0 ? "PROCESSING" : "UNLABELLED"; + } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 566ef2d3a..e8f84b81c 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3563,3 +3563,28 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact_history ( CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id), CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史'; + +-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。 +-- 可重复执行,目标库 baiyue_lastleg。 +USE baiyue_lastleg; + +-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。 +-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND constraint_name='chk_ll_return_outbound_movement_delta'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', + 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND index_name='uk_ll_return_outbound_carton_sku'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 277f26400..e8d1e12a8 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -824,8 +824,8 @@ paths: responses: { '200': { description: 出库单分页 } } /api/platform/v1/last-leg/outbound-orders/return: post: - summary: 创建退货出库单,支持多个散件SKU分别填写出库数量 - description: 权限 platform:overseas-warehouse:outbound:create;散件SKU来源仅允许PARCEL可用库存,不包含箱内库存;实体箱按箱或托盘出库。items逐来源指定SKU及数量,与cartonMarks一一对应,不可同时提交顶层skuCode或quantity,不支持PALLET方式。多个SKU生成同一张出库单和派送单,PDA逐SKU按件数拣货。旧版skuCode与quantity继续支持单SKU来源排序分配。未拣货前占用整个使用来源,拣货后来源余数恢复可用。相同幂等键改数量拒绝。 + summary: 按包裹或箱标签创建整实物退货出库单 + description: 权限 platform:overseas-warehouse:outbound:create;以包裹或箱为最小单位,cartonMarks为实际标签码,PARCEL可包含多个SKU,整包裹/箱或整托出库。新签收和新合箱须先扫码确认贴标。部分包裹及部分箱均须通过合箱生成独立新标签箱后才能出库。提交skuCode、quantity或非空items直接拒绝,不修改库存。来源合箱占用或尚未清点时拒绝,PDA按实物标签扣全部内容,同键重放不重复扣减。 security: [{ bearerAuth: [] }] parameters: [{ name: customerId, in: query, required: true, schema: { type: string } }] requestBody: @@ -840,20 +840,6 @@ paths: overseasWarehouseId: { type: string, maxLength: 128 } returnNo: { type: string, maxLength: 128 } cartonMarks: { type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: { type: string, maxLength: 128 } } - skuCode: { type: string, maxLength: 128, nullable: true } - quantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU计划件数,不得超过所选来源总数;省略出全部,只允许skuCode已填且非PALLET维度,不接受小数 } - items: - type: array - nullable: true - minItems: 1 - maxItems: 200 - items: - type: object - required: [cartonMark, skuCode, quantity] - properties: - cartonMark: { type: string, maxLength: 128, description: 散件库存来源标识,非实体箱号 } - skuCode: { type: string, maxLength: 128 } - quantity: { type: integer, format: int64, minimum: 1, description: 本来源本次出库件数,不接受小数,不得超过可用数量 } unitType: { type: string, enum: [BOX, PALLET], nullable: true } remark: { type: string, maxLength: 1000, nullable: true } idempotencyKey: { type: string, maxLength: 255 } @@ -1764,7 +1750,7 @@ paths: /api/merchant/v1/last-leg/return-orders/{returnNo}: get: summary: 客户查询退货订单与签收批次 - description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹散件不生成实物箱明细。 + description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹作为一个独立实物明细,保留同包裹全部SKU及可打印标签。 security: [{ bearerAuth: [] }] parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } @@ -1851,11 +1837,11 @@ paths: - { name: returnNo, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string } } responses: - '200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt;total 为实际箱数 } + '200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt、relabelStatus、relabelledQuantity、relabelReservedQuantity;total 为实际箱数。换标状态取实际当前或最新轮次事实,申请占用为未完成申请件数,两者不从可用库存差值推算 } /api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}: get: summary: 客户查询退货箱明细 - description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量。 + description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量;relabelStatus 为 UNLABELLED/PROCESSING/PARTIALLY_COMPLETED/COMPLETED,relabelledQuantity 为当前或最新轮次实际商品换标件数,relabelReservedQuantity 为未完成申请件数。items.availableQuantity 为实物可用库存,历史换标不会永久扣减下一轮可申请量。 security: [{ bearerAuth: [] }] parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }] responses: { '200': { description: 当前客户退货箱明细 }, '404': { description: 当前客户下退货箱不存在 } } @@ -3118,6 +3104,10 @@ components: RelabelInventoryCandidate: type: object properties: + relabelStatus: { type: string, nullable: true, enum: [UNLABELLED, PROCESSING, PARTIALLY_COMPLETED, COMPLETED], description: 退货实物按实际完成件数和未完成申请占用计算 } + relabelledPackages: { type: integer, format: int64, description: 退货实际已完成商品换标件数,不从可用库存差值推导 } + relabelledQuantity: { type: integer, format: int64, minimum: 0 } + relabelReservedQuantity: { type: integer, format: int64, minimum: 0, description: 未完成申请件数,取消或完成后释放 } inventoryBalanceId: { type: string } inboundOrderId: { type: string } inboundOrderNo: { type: string } @@ -3132,7 +3122,7 @@ components: availablePackages: { type: integer, format: int64 } version: { type: integer, format: int64 } positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } - sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] } + sourceType: { type: string, enum: [INBOUND, RETURN_CARTON, RETURN_PARCEL] } returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 } cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 } skuItems: diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index b0dad9ad6..c08d1aba7 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -272,11 +272,82 @@ paths: itemId: { type: string, pattern: '^[0-9]+$' } skuIndex: { type: integer, minimum: 0 } oldSku: { type: string, maxLength: 255 } + cartonMark: { type: string, maxLength: 128, description: 退货SKU作业必填,须扫描当前实体来源包裹或箱 } + newSkuCode: { type: string, maxLength: 128, description: 退货SKU作业必填,须与批准的目标标签一致 } + quantity: { type: integer, minimum: 1, description: 退货SKU作业必填,须与本明细批准件数一致 } expectedVersion: { type: integer, minimum: 0 } idempotencyKey: { type: string, maxLength: 255 } responses: '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } '409': { description: 已处理、映射不符、认领人或版本冲突 } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-skus/complete: + post: + summary: 扫描来源包裹或箱及SKU登记退货按件换标实际完成量 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [itemId, quantity, expectedVersion, idempotencyKey, cartonMark, skuCode] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + quantity: { type: integer, minimum: 1 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + cartonMark: { type: string, maxLength: 128 } + skuCode: { type: string, maxLength: 128 } + responses: { '200': { description: 更新后Detail;同键同请求重放 }, '409': { description: 来源、SKU、剩余件数或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-skus/complete-total: + post: + summary: 单一来源的退货SKU按件完成 + description: 同SKU涉及多个来源包裹或箱时拒绝自动分配,必须逐来源扫码。 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [skuCode, quantity, expectedVersion, idempotencyKey] + properties: + skuCode: { type: string } + quantity: { type: integer, minimum: 1 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string } + responses: { '200': { description: 更新后Detail }, '409': { description: 多来源或数量冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-cartons/pack: + post: + summary: 将本任务已完成的退货SKU件数隔离合箱为独立新箱 + description: 必须由认领人在PROCESSING阶段完成所有申请明细后执行;原包裹余量守恒,保留跨退货单追溯。响应Detail的items切换为新箱,history的RETURN_PACK.detail JSON含round、sourceCartonMarks、sourceDeductions、cartonMark、quantity和label.printableHtml。标签为仓库识别标签,与商品换标资料分开。同键重放原结果。 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sourceCartonMarks, idempotencyKey, length, width, height, weight] + properties: + sourceCartonMarks: { type: array, minItems: 1, maxItems: 100, items: { type: string } } + targetCartonMark: { type: string, nullable: true, description: 必须为空,由系统生成独立新箱 } + idempotencyKey: { type: string, maxLength: 255 } + sourceDeductions: + type: array + description: 可省略由服务端生成;填写时按来源箱和SKU汇总必须与本任务完成明细一致 + items: + type: object + required: [cartonMark, skuCode, quantity] + properties: + cartonMark: { type: string } + skuCode: { type: string } + quantity: { type: integer, format: int64, minimum: 1 } + length: { type: number, exclusiveMinimum: true, minimum: 0 } + width: { type: number, exclusiveMinimum: true, minimum: 0 } + height: { type: number, exclusiveMinimum: true, minimum: 0 } + weight: { type: number, exclusiveMinimum: true, minimum: 0 } + responses: { '200': { description: 更新后Detail含新箱识别标签 }, '409': { description: 未完成申请、来源变动或数量冲突 } } /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: post: summary: 登记一件有原因的跳过事实 @@ -751,6 +822,8 @@ components: type: object required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history] properties: + deliveryMethod: { type: string, nullable: true, description: 申请真实运输方式,留仓为空 } + requiresPalletBinding: { type: boolean, description: 退货任务仅TRUCK为true,留仓及快递为false } relabelNo: { type: string } customerOrderNo: { type: string, nullable: true } labelDeadline: { type: string, format: date-time, nullable: true } @@ -791,7 +864,7 @@ components: relabelFileUrl: { type: string, nullable: true } cartonMark: { type: string, nullable: true, description: 我方箱唛 } fbaCartonMark: { type: string, nullable: true, description: 新FBA箱唛 } - operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU] } + operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU, RETURN_BULK_SKU_LABEL] } skuItems: { type: array, items: { $ref: '#/components/schemas/RelabelSkuItem' } } expectedPackages: { type: integer } relabelledPackages: { type: integer } @@ -799,6 +872,8 @@ components: exceptionPackages: { type: integer } cartonProcessedPackages: { type: integer, description: 本轮箱唛作业已完成箱数,混合单独立计数 } skuProcessedPackages: { type: integer, description: 本轮SKU作业已完成箱数,两项均完成才增加整箱已换标数 } + returnCartonId: { type: string, nullable: true, description: 真实退货包裹或箱ID } + returnQuantity: { type: integer, format: int64, minimum: 0, description: 当前实物包裹或箱全部件数;不是申请量 } RelabelSkuItem: type: object required: [skuCode, newSkuCode, quantity] @@ -876,7 +951,7 @@ components: inventoryPositionId: { type: string, nullable: true, pattern: '^[0-9]+$', description: 普通库存使用真实仓位键;退货箱库存没有该仓位,提交 null,不提交空字符串或退货库存内部ID } packages: { type: integer, format: int64, minimum: 1 } productQuantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU退货明细存在returnSkuQuantity时必填且必须等于计划件数;不接受小数。历史无快照明细可省略 } - skuCode: { type: string, maxLength: 128, nullable: true, description: PARCEL散件单SKU扫码值;已装箱来源必须扫描箱唛 } + skuCode: { type: string, maxLength: 128, nullable: true, description: 仅遗留已创建SKU任务使用;新退货任务必须扫描包裹或箱标签 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } cartonMarks: type: array diff --git a/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml b/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml index b579ee2a8..d526223c8 100644 --- a/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 退货合箱计划与箱级上架 version: 1.0.0 - description: 散SKU查询客户全部同仓PARCEL可用库存;箱合并精确指定来源箱唛并扫描已有目标箱。托盘仅为可选载体。合箱不新增订单货量。 + description: 包裹可包含多个SKU并拥有独立标签;整包裹/箱可直接出库,部分数量须精确选择来源包裹/箱合箱成新标签箱。托盘仅为可选载体。合箱不新增订单货量。 security: [{ bearerAuth: [] }] paths: /api/platform/v1/last-leg/return-packing-plans: @@ -17,7 +17,7 @@ paths: responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } post: summary: 创建并锁定来源库存 - description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU只锁散件指定数量。 + description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU锁定所选包裹/箱的指定数量,来源实物在计划完成或取消前不可出库。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Create' } } } } responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } /api/platform/v1/last-leg/return-packing-plans/{planNo}/review: @@ -46,16 +46,22 @@ paths: /api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}/complete: post: summary: 多箱分配并完成计划 - description: SKU目标总量须等于锁定量。BOX先选择新增目标箱或扫描已有目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。 + description: SKU目标总量须等于锁定量。SKU仅支持新增目标箱;BOX整箱可扫描已有目标箱,部分扣除仅支持新增目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。 parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Complete' } } } } responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } /api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}: get: summary: 扫描可用来源或目标箱 - description: 仅同仓AVAILABLE实物箱,PARCEL不作为箱选择。 + description: 仅同仓AVAILABLE包裹或箱,返回其全部SKU明细。 parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}/confirm-label: + post: + summary: 扫描已贴好的包裹或箱标签 + description: 绑定仓且AVAILABLE实物,幂等确认仓库识别标签;整包裹或箱无需确认此标签也可创建出库单。此操作不登记商品换标完成量。 + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 标签快照及业务响应 }, '403': { description: 权限或角色不符 } } /api/pda/v1/last-leg/return-inventory/cartons/merge-boxes: post: summary: 来源箱并入已有目标箱 @@ -112,6 +118,7 @@ components: returnNos: { type: array, minItems: 1, maxItems: 100, items: { type: string } } mode: { type: string, enum: [SKU, BOX] } sourceCartonMarks: { type: array, maxItems: 500, items: { type: string } } + sourceDeductions: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/SourceDeduction' }, description: 部分合箱精确来源及数量,与目标SKU合计一致 } boxes: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/Box' } } remark: { type: string, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 8c05d0096..2b26d573e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -67,6 +67,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -209,105 +210,52 @@ class DeliveryTransferServiceTest { var candidates = service.returnOutboundCandidates("TENANT-A", "WH-1"); - assertEquals(List.of("RC-PAL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList()); + assertEquals(List.of("RC-PAL", "RC-PARCEL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList()); + } + + + + + + + + @Test + void directPartialReturnOutboundRequiresPackingBeforeAnyInventoryLock() { + for (var request : List.of( + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "partial-sku", "SKU-A", "BOX", 1L), + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("BOX-1"), null, "partial-box", null, "BOX", null, + List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-1", "SKU-A", 1L))))) { + var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", request)); + assertTrue(error.getReason().contains("先合箱")); + } + verify(returnCartons, never()).lockMark(anyString()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); } @Test - void singleSkuReturnOutboundReservesParcelAndCreatesDelivery() { - ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null); - parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(3L); - ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); - line.setSkuCode("SKU-A"); line.setQuantity(3L); - when(returnCartons.lockMark("RC-PARCEL")).thenReturn(parcel); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line)); - - service.createReturnOutbound("TENANT-A", "U-1", - new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("RC-PARCEL"), null, "sku-return-1", "SKU-A")); - - ArgumentCaptor item = ArgumentCaptor.forClass(OutboundPalletItem.class); - ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); - verify(outboundPallets).insert(item.capture()); - verify(deliveryOrders).insert(delivery.capture()); - assertEquals("SKU-A", item.getValue().getSkuCode()); - assertEquals("RC-PARCEL", item.getValue().getCartonMarks()); + void wholeMultiSkuParcelCreatesBarcodePickWithoutSkuQuantity() { + var parcel = returnCarton("RP-1", null); parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(5L); + var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(3L); + var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(2L); + when(returnCartons.lockMark("RP-1")).thenReturn(parcel); + when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a,b)); + service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver","WH-1","RT-1",List.of("RP-1"),null,"whole-package",null,"BOX")); + var capture=ArgumentCaptor.forClass(OutboundPalletItem.class); verify(outboundPallets).insert(capture.capture()); + assertEquals("RP-1", capture.getValue().getCartonMarks()); + assertNull(capture.getValue().getSkuCode()); assertNull(capture.getValue().getReturnSkuQuantity()); assertEquals("OUTBOUND_PENDING", parcel.getStatus()); - assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); - assertEquals(1, delivery.getValue().getPackageCount()); } @Test - void partialSkuOutboundDistributesSixPiecesAcrossTwoFivePieceSources() { - var first = returnCarton("RC-1", null); first.setId(901L); first.setTotalQuantity(5L); - var second = returnCarton("RC-2", null); second.setId(902L); second.setTotalQuantity(5L); - first.setSourceUnitType("PARCEL"); second.setSourceUnitType("PARCEL"); - when(returnCartons.lockMark("RC-1")).thenReturn(first); when(returnCartons.lockMark("RC-2")).thenReturn(second); - var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line)); - when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(line)); - service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("RC-1", "RC-2"), null, "partial-six", "SKU-A", "BOX", 6L)); - var capture = ArgumentCaptor.forClass(OutboundPalletItem.class); - verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture()); - assertEquals(List.of(5L, 1L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList()); - } - - @Test - void singleSkuReturnOutboundRejectsPackedSingleSkuWithoutReservingInventory() { - ReturnInventoryCarton carton = returnCarton("RC-BOX", "PAL-1"); - carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L); - when(returnCartons.lockMark("RC-BOX")).thenReturn(carton); - var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", - new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-BOX"), null, "packed-sku", "SKU-A"))); - assertTrue(error.getReason().contains("单SKU出库只允许散件库存")); - verify(outboundOrders, never()).insert(any(OutboundOrder.class)); - verify(returnCartons, never()).updateById(any(ReturnInventoryCarton.class)); - assertEquals("AVAILABLE", carton.getStatus()); - } - - @Test - void multipleLooseSkusCreateOneOutboundWithExactPerSourceQuantities() { - var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(5L); - var second = returnCarton("SP-B", null); second.setId(902L); second.setSourceUnitType("PARCEL"); second.setTotalQuantity(8L); - when(returnCartons.lockMark("SP-A")).thenReturn(first); when(returnCartons.lockMark("SP-B")).thenReturn(second); - var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(5L); - var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(8L); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a)); when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(b)); - service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("SP-A", "SP-B"), null, "multi-loose", null, "BOX", null, - List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("SP-B", "SKU-B", 3L)))); - var capture = ArgumentCaptor.forClass(OutboundPalletItem.class); - verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture()); - assertEquals(List.of("SKU-A", "SKU-B"), capture.getAllValues().stream().map(OutboundPalletItem::getSkuCode).toList()); - assertEquals(List.of(2L, 3L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList()); - verify(outboundOrders).insert(any(OutboundOrder.class)); verify(deliveryOrders).insert(any(DeliveryOrder.class)); - } - - @Test - void looseItemQuantityCannotExceedItsOwnSource() { - var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(1L); - when(returnCartons.lockMark("SP-A")).thenReturn(first); - var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(1L); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a)); - var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("SP-A"), null, "over-source", null, "BOX", null, List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L))))); - assertTrue(error.getReason().contains("超过")); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); - } - - @Test - void singleSkuReturnOutboundRejectsMixedCarton() { - ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null); - mixed.setSourceUnitType("PARCEL"); - mixed.setId(901L); mixed.setTotalQuantity(5L); - ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L); - ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L); - when(returnCartons.lockMark("RC-MIXED")).thenReturn(mixed); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB)); - - assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", - new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("RC-MIXED"), null, "sku-return-2", "SKU-A"))).getStatusCode().value()); - verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + void newlyReceivedParcelCanOutboundBeforeItsLabelIsConfirmed() { + var parcel = returnCarton("RP-1", null); parcel.setLabelStatus("WAIT_LABEL"); + when(returnCartons.lockMark("RP-1")).thenReturn(parcel); + var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(3L); + when(returnCartonItems.lockCarton(anyLong())).thenReturn(List.of(line)); + service.createReturnOutbound("TENANT-A", "U-1", + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "waiting-label")); + verify(outboundOrders).insert(any(OutboundOrder.class)); + assertEquals("OUTBOUND_PENDING", parcel.getStatus()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index a227a7df6..d6a5b1436 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -71,27 +71,13 @@ class ReturnOutboundClosureTest { verify(outbounds, times(1)).insert(any(OutboundOrder.class)); } - @Test void singleSkuPackedBoxIsExcludedFromLooseOutbound() { - assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1"))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("散件库存"); - verify(items, never()).insert(any(OutboundPalletItem.class)); - assertThat(carton.getStatus()).isEqualTo("AVAILABLE"); - } - @Test void singleSkuCannotConsumeMixedSkuCarton() { - ReturnInventoryCartonItem other = new ReturnInventoryCartonItem(); other.setSkuCode("SKU2"); other.setQuantity(1L); - var first = contents.lockCarton(10L).get(0); - when(contents.lockCarton(10L)).thenReturn(List.of(first, other)); - assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1"))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("SKU"); - verify(outbounds, never()).insert(any(OutboundOrder.class)); - } - @Test void singleSkuParcelReservesAndKeepsScanSku() { + + @Test void wholeParcelUsesItsPhysicalBarcode() { carton.setSourceUnitType("PARCEL"); - service.createReturnOutbound("C1", "USER", command("SKU1")); - verify(items).insert(argThat((OutboundPalletItem i) -> "SKU1".equals(i.getSkuCode()))); - assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING"); + service.createReturnOutbound("C1", "USER", command(null)); + verify(items).insert(argThat((OutboundPalletItem i) -> i.getSkuCode() == null && i.getReturnSkuQuantity() == null && "RC#1-1".equals(i.getCartonMarks()))); } @Test void refusesPackingReservedQuantity() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index dc82ab31a..0b8dff334 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -123,29 +123,6 @@ class ReturnOutboundPdaPersistenceTest { assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum()); } - @Test void multipleLooseSkusPersistPickAndDeductTheirOwnPieceQuantitiesWithReplayProtection() { - jdbc.update("UPDATE ll_return_inventory_carton SET source_unit_type='PARCEL' WHERE id IN (1,2)"); - jdbc.update("UPDATE ll_return_inventory_carton_item SET sku_code='SKU-B' WHERE id=2"); - jdbc.update("UPDATE ll_return_inventory_balance SET total_quantity=5,available_quantity=5,carton_quantity=1 WHERE id=1"); - jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(10,'C1','WH-1','SKU-B',5,5,1)"); - var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1", - List.of("BOX-A", "BOX-B"), null, "multi-sku", null, "BOX", null, - List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("BOX-B", "SKU-B", 3L)))); - var detail = service.pdaTaskDetail("WH-1", task.taskNo()); - assertEquals(2, detail.items().size()); - for (var item : detail.items()) { - String sku = item.skuCode(); long quantity = "SKU-A".equals(sku) ? 2 : 3; - var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "pick-" + sku, List.of(), sku, quantity); - service.pdaPick("WH-1", "operator", task.taskNo(), request); - service.pdaPick("WH-1", "operator", task.taskNo(), request); - } - assertEquals(3L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class)); - assertEquals(2L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class)); - assertEquals(5L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class)); - assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); - assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_order", Integer.class)); - assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order", Integer.class)); - } @Test void wholeReturnPalletRejectsPartialScansAndChangedContentsThenShipsAtomically() { jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id IN (1,2)"); var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound( 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 e325d8e1d..74de56c1d 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 @@ -308,7 +308,7 @@ class PdaRelabelServiceTest { } @Test - void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() { + void returnSkuTotalRejectsAutomaticAllocationAcrossSourceCartons() { application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100); item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100"); item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A"); @@ -319,15 +319,42 @@ class PdaRelabelServiceTest { 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")); + assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1"))); + assertTrue(persistedFacts.isEmpty()); + } - 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()); + @Test void returnSkuScanRequiresPhysicalSourceNewSkuAndApprovedQuantity() { + application.setStatus("PROCESSING"); application.setDeliveryMethod("EXPRESS"); + item.setOperationType("SKU_LABEL"); item.setReturnCartonId(20L); item.setCartonMark("PARCEL-A"); + item.setNotifiedPackageCount(1); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD\",\"newSkuCode\":\"NEW\",\"quantity\":3}]"); + var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + carton.setId(20L); carton.setCountStatus("CONFIRMED"); carton.setTotalQuantity(10L); + when(cartons.selectById(20L)).thenReturn(carton); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task("PROCESSING", 0)); + when(items.selectOne(any())).thenReturn(item); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-source", "PARCEL-B", "NEW", 3))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-quantity", "PARCEL-A", "NEW", 2))); + assertTrue(persistedFacts.isEmpty()); + var result = service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "good", "PARCEL-A", "NEW", 3)); + assertEquals(1, result.relabelledPackages()); assertEquals("EXPRESS", result.deliveryMethod()); + assertEquals(false, result.requiresPalletBinding()); assertEquals(10, result.items().get(0).returnQuantity()); + var second = item(); second.setId(11L); second.setReturnCartonId(20L); second.setCartonMark("PARCEL-A"); + second.setOperationType("SKU_LABEL"); second.setNotifiedPackageCount(1); + second.setSkuRelabelItemsJson(item.getSkuRelabelItemsJson()); + when(items.selectList(any())).thenReturn(List.of(item, second)); + when(items.selectOne(any())).thenReturn(second); + var again = service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(11L, 0, "OLD", 1, "same-merged-box", "PARCEL-A", "NEW", 3)); + assertEquals(2, again.relabelledPackages()); assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null)); - assertEquals(1, result.version()); } @Test 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 f2260afd3..dbc85a796 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 @@ -452,7 +452,7 @@ class RelabelApplicationServiceTest { ReturnOrder returnOrder = new ReturnOrder(); returnOrder.setId(41L); returnOrder.setCustomerId("10001"); returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING"); - when(returnOrders.selectOne(any())).thenReturn(returnOrder); + when(returnOrders.selectList(any())).thenReturn(List.of(returnOrder)); service.changeStatus("10001", "FBR-REVIEW", "operator-2", new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java index 806625f7b..60c35f0ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java @@ -16,6 +16,27 @@ import static org.mockito.ArgumentMatchers.*; import static org.junit.jupiter.api.Assertions.*; class RelabelBindingGuardTest { + @Test void partialReturnSkuMustBeIsolatedAndLooseFullCartonOnlyRequiresPalletForTruck() { + var guard = new RelabelBindingGuard(); + var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class); + var lines = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); + var cartons = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories); + ReflectionTestUtils.setField(guard, "returnLines", lines); ReflectionTestUtils.setField(guard, "returnCartons", cartons); + ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); + var item = new RelabelItem(); item.setReturnCartonId(3L); item.setCartonMark("PARCEL"); + item.setOperationType("SKU_LABEL"); item.setNotifiedPackageCount(1); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]"); + when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of()); + var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); stock.setSkuCode("A"); stock.setQuantity(10L); + when(lines.lockCarton(3L)).thenReturn(List.of(stock)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + stock.setQuantity(3L); + assertDoesNotThrow(() -> guard.requireBound(app)); + app.setDeliveryMethod("EXPRESS"); assertDoesNotThrow(() -> guard.requireBound(app)); + app.setDeliveryMethod("TRUCK"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + } @Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() { var guard = new RelabelBindingGuard(); var items = mock(RelabelItemMapper.class); 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 de7433c01..340a2221f 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 @@ -36,9 +36,9 @@ class RelabelReturnCartonCandidateTest { ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem(); cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN"); cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L); - when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton)); + when(cartons.findRelabelCandidates("10001", "WH-1")).thenReturn(List.of(carton)); when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem)); - when(cartons.relabelledQuantity(9001L, "SKU-RETURN")).thenReturn(1L); + when(cartons.totalRelabelledQuantity(9001L)).thenReturn(1L); List result = service.candidates("10001", "WH-1", "RC-WH"); @@ -46,8 +46,14 @@ class RelabelReturnCartonCandidateTest { assertEquals("RETURN_CARTON", result.get(0).sourceType()); assertEquals(9001L, result.get(0).returnCartonId()); assertEquals("RC-WH-0001", result.get(0).cartonMark()); - assertEquals(11, result.get(0).availablePackages()); + assertEquals(12, result.get(0).availablePackages()); assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); - assertEquals(11, result.get(0).skuItems().get(0).quantity()); + assertEquals(12, result.get(0).skuItems().get(0).quantity()); + assertEquals(1, result.get(0).relabelledQuantity()); + assertEquals("PARTIALLY_COMPLETED", result.get(0).relabelStatus()); + cartonItem.setPackingReservedQuantity(4L); + result = service.candidates("10001", "WH-1", "RC-WH"); + assertEquals(8, result.get(0).availablePackages()); + assertEquals(1, result.get(0).relabelledQuantity(), "合箱预占不能冒充实际换标完成量"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java new file mode 100644 index 000000000..abb574200 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java @@ -0,0 +1,107 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.returnreceipt.dto.*; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ReturnRelabelPackingServiceTest { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void skuPackingMovesCompletionToIndependentCartonAndKeepsOriginalSourceMarks(boolean inheritedCompletion) { + var service = new ReturnRelabelPackingService(); + var cartons = mock(ReturnInventoryCartonMapper.class); + var items = mock(RelabelItemMapper.class); + var inventory = mock(ReturnInventoryService.class); + ReflectionTestUtils.setField(service, "cartons", cartons); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "json", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setRelabelNo("RL1"); + var first = item(10L, 20L, "A"); var second = item(11L, 21L, "B"); + var a = carton(20L, "A"); var b = carton(21L, "B"); var target = carton(22L, "NEW"); + var history = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "histories", history); + if (inheritedCompletion) { + var prior = item(9L, 20L, "A"); prior.setApplicationId(99L); + prior.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":6}]"); + when(items.priorCompletedReturnItems("2", 20L, 1L)).thenReturn(List.of(prior)); + var facts = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper.class); + var fact = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact(); fact.setItemId(9L); fact.setQuantity(1); + when(facts.selectList(any())).thenReturn(List.of(fact)); + ReflectionTestUtils.setField(service, "facts", facts); + var physical = mock(ReturnInventoryCartonItemMapper.class); + var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU"); line.setQuantity(6L); + when(physical.lockCarton(20L)).thenReturn(List.of(line)); + ReflectionTestUtils.setField(service, "cartonItems", physical); + ReflectionTestUtils.setField(service, "completionLedger", mock(ReturnRelabelCompletionLedgerMapper.class)); + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(100L); + when(tasks.find("2", 1L)).thenReturn(task); ReflectionTestUtils.setField(service, "tasks", tasks); + } + when(cartons.lockMark("A")).thenReturn(a); when(cartons.lockMark("B")).thenReturn(b); + when(cartons.lockMark("NEW")).thenReturn(target); + when(inventory.mergeBoxes(any(), any())).thenAnswer(call -> { + var request = (ReturnInventoryRequests.BoxMerge)call.getArgument(1); + assertEquals(List.of("A", "B"), request.sourceCartonMarks()); + assertNull(request.targetCartonMark()); + assertEquals(List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU", 3), + new ReturnInventoryRequests.SourceDeduction("B", "SKU", 3)), request.sourceDeductions()); + return new ReturnInventoryResponses.MergeResult("NEW", "SKU", 6, null); + }); + var result = service.pack(new PdaWarehouseIdentity("op", "picker", 42L), app, List.of(first, second), + new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "key", null, + BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE)); + assertEquals("NEW", result.cartonMark()); + assertEquals(22L, first.getReturnCartonId()); assertEquals(22L, second.getReturnCartonId()); + assertEquals("A", first.getSourceCartonMark()); assertEquals("B", second.getSourceCartonMark()); + assertEquals("RELABEL_PENDING", target.getStatus()); + verify(cartons).clearRelabel(20L); verify(cartons).clearRelabel(21L); + verify(items).updateById(first); verify(items).updateById(second); + if (inheritedCompletion) { + var saved = org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class); + verify(history).insert(saved.capture()); + assertEquals("RETURN_COMPLETION_TRANSFER", saved.getValue().getAction()); + assertTrue(saved.getValue().getDetailJson().contains("\"sourceItemIds\":[\"9\"]")); + assertTrue(saved.getValue().getDetailJson().contains("\"quantity\":3")); + assertTrue(saved.getValue().getDetailJson().contains("\"targetCartonMark\":\"NEW\"")); + } else verifyNoInteractions(history); + } + + @Test void wrongSourceAndDifferentDestinationCannotEnterInventoryMutation() { + var service = new ReturnRelabelPackingService(); + var inventory = mock(ReturnInventoryService.class); + ReflectionTestUtils.setField(service, "inventory", inventory); + var app = new RelabelApplication(); + var a = item(10L, 20L, "A"); var b = item(11L, 21L, "B"); b.setDestinationFbaWarehouseCode("OTHER"); + assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app, + List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong"))); + assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app, + List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest"))); + verifyNoInteractions(inventory); + } + private RelabelItem item(long id, long cartonId, String mark) { + var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(cartonId); + item.setCartonMark(mark); item.setSourceCartonMark(mark); item.setOperationType("SKU_LABEL"); + item.setNotifiedPackageCount(1); item.setDestinationFbaWarehouseCode("FBA"); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":3}]"); + return item; + } + private ReturnInventoryCarton carton(long id, String mark) { + var carton = new ReturnInventoryCarton(); carton.setId(id); carton.setCartonMark(mark); + carton.setCustomerId("2"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING"); + return carton; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index f5485a34a..a9ae1aab5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -127,7 +127,7 @@ class ReturnInventoryServiceTest { } @Test - void pendingReceiptDetailsDoNotExposeProvisionalAveragesAndParcelsHaveNoBoxes() { + void pendingReceiptDetailsDoNotExposeProvisionalAveragesForPhysicalUnits() { var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET"); var box = new ReturnInventoryCarton(); box.setCartonMark("RC#2-1"); box.setCountStatus("PENDING"); box.setTotalQuantity(5L); when(cartons.listReceipt(21L)).thenReturn(List.of(box)); @@ -135,7 +135,7 @@ class ReturnInventoryServiceTest { assertThat(detail.get(0).countStatus()).isEqualTo("PENDING"); assertThat(detail.get(0).items()).isEmpty(); receipt.setUnitType("PARCEL"); - assertThat(service.receiptContents(receipt)).isEmpty(); + assertThat(service.receiptContents(receipt)).hasSize(1); verifyNoInteractions(cartonItems); } @@ -150,13 +150,14 @@ class ReturnInventoryServiceTest { } @Test - void parcelSigningStoresLooseSkuSourceWithoutGeneratingCartonNumberOrIncreasingBoxCount() { + void parcelSigningCreatesOneLabelledPhysicalUnitWithoutIncreasingBoxCount() { var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1"); var source = service.createReceiptCarton(completedOrder(), receipt, null, List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3))); - assertThat(source.getCartonMark()).startsWith("SP"); + assertThat(source.getCartonMark()).startsWith("RP"); assertThat(source.getSourceUnitType()).isEqualTo("PARCEL"); - verifyNoInteractions(numbers, operations); + verifyNoInteractions(numbers); + assertThat(source.getLabelStatus()).isEqualTo("WAIT_LABEL"); verify(balances).adjust(anyLong(), anyString(), anyString(), eq("A"), eq("商品A"), eq(3L), eq(3L), eq(0L)); } @@ -227,7 +228,7 @@ class ReturnInventoryServiceTest { void cartonPageCountsPhysicalCartonsAndKeepsTheAuthenticatedCustomerScope() { var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", null, "AVAILABLE", "RT-1"); ReturnInventoryCarton carton = new ReturnInventoryCarton(); - carton.setCartonMark("RC-SECOND"); + carton.setId(31L); carton.setCartonMark("RC-SECOND"); carton.setSkuTypes(2); carton.setTotalQuantity(5L); when(cartons.merchantCount("10001", query)).thenReturn(2L); @@ -369,6 +370,32 @@ class ReturnInventoryServiceTest { verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L); } + @Test void completedLabelQuantityCannotBeGuessedWhenSplittingAStoredCarton() { + prepareConcurrentSource(); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); + when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(1L); + var error = assertThrows(ResponseStatusException.class, () -> service.merge( + new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(1))); + assertThat(error.getReason()).contains("不能自动分配来源", "独立新箱"); + verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class)); + verify(cartons, never()).insert(any(ReturnInventoryCarton.class)); + } + + @Test void wholeCartonMergeCarriesItsCompletedRelabelFactsToTheTarget() { + var source = prepareConcurrentSource(); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); + when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(2L); + when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); + var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class); + ReflectionTestUtils.setField(service, "relabelItems", relabel); + service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2)); + var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class); + verify(cartons).insert(target.capture()); + verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET"); + assertThat(source.getTotalQuantity()).isZero(); + } + @Test void sameKeyInsertRaceReplaysCommittedResultAndLabelUsingOnlyCurrentReads() { var request = mergeRequest(1); @@ -468,11 +495,11 @@ class ReturnInventoryServiceTest { } @Test - void planCreationLocksExactSkuAndCancelReleasesOnlyItsReservation() { + void planCreationLocksPhysicalSourceAndCancelReleasesReservation() { var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE"); source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1"); source.setStatus("AVAILABLE"); source.setSourceUnitType("PARCEL"); - var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(1L); + var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(0L); when(orders.lockNo("RT-1")).thenReturn(completedOrder()); when(cartons.packingAvailable("10001", "42")).thenReturn(List.of(source)); when(cartons.lockMark("RC-SOURCE")).thenReturn(source); @@ -483,12 +510,14 @@ class ReturnInventoryServiceTest { var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU", List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key"); var created = service.createPackingPlan("platform", request); - assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(3); + assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(2); + assertThat(source.getStatus()).isEqualTo("PACKING_RESERVED"); var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class); verify(packingPlans).insert(captor.capture()); when(packingPlans.lockNo("PLAN-1")).thenReturn(captor.getValue()); assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED"); - assertThat(line.getPackingReservedQuantity()).isEqualTo(1); + assertThat(line.getPackingReservedQuantity()).isEqualTo(0); + assertThat(source.getStatus()).isEqualTo("AVAILABLE"); verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L)); } @@ -502,6 +531,35 @@ class ReturnInventoryServiceTest { verifyNoInteractions(cartonItems, balances); } + @Test + void partialPhysicalBoxCannotBeMergedIntoAnExistingTarget() { + var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE"); + source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1"); + source.setStatus("AVAILABLE"); source.setSourceUnitType("BOX"); + when(cartons.lockMark("RC-SOURCE")).thenReturn(source); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 5))); + var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), "RC-TARGET", "partial-existing", + List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null); + var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("operator-1", "作业员", 42L), request)); + assertThat(error.getReason()).contains("独立新箱"); + verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class)); + verify(cartons, never()).insert(any(ReturnInventoryCarton.class)); + } + + @Test + void confirmingPhysicalLabelIsIdempotentAndBoundToWarehouse() { + var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE"); + source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1"); + source.setStatus("AVAILABLE"); source.setLabelStatus("WAIT_LABEL"); + when(cartons.lockMark("RC-SOURCE")).thenReturn(source); + when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2))); + var identity = new PdaWarehouseIdentity("operator-1", "作业员", 42L); + service.confirmLabel(identity, "RC-SOURCE"); service.confirmLabel(identity, "RC-SOURCE"); + assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); + verify(cartons, org.mockito.Mockito.times(1)).updateById(source); + assertThrows(ResponseStatusException.class, () -> service.confirmLabel(new PdaWarehouseIdentity("operator-2", "其他仓", 43L), "RC-SOURCE")); + } + private ReturnInventoryCarton prepareConcurrentSource() { ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L); ReturnInventoryCarton snapshot = new ReturnInventoryCarton(); 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 4205b5c2c..604e08124 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 @@ -227,10 +227,13 @@ class ReturnReceiptServiceTest { 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(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); 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))); + when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50))); + when(cartonItems.lockCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40))); var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate(List.of( @@ -263,7 +266,7 @@ class ReturnReceiptServiceTest { } @Test - void secondRelabelCannotAllocateAlreadyLabelledQuantity() { + void completedReturnCartonCanStartAnotherRelabelRound() { ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100); order.setLastlegOrderId(99L); order.setRelabelNo("RL-PREVIOUS"); LastlegOrder tail = new LastlegOrder(); tail.setId(99L); @@ -275,16 +278,17 @@ class ReturnReceiptServiceTest { when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-SECOND"); when(lastlegOrders.selectById(99L)).thenReturn(tail); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton)); + when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100))); - when(cartons.relabelledQuantity(101L, "SKU-A")).thenReturn(100L); - var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel( + when(cartons.lockId(101L)).thenReturn(carton); + when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100))); + var result = service.createRelabel( "customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate( List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)), - "SKU_TOTAL", "200", "XLX6", "second-key", "second attempt"))); - assertEquals(409, error.getStatusCode().value()); - assertTrue(error.getReason().contains("可用实收数量不足")); - verify(relabelItems, never()).insert(any(RelabelItem.class)); - verify(cartons, never()).updateById(any(ReturnInventoryCarton.class)); + "SKU_TOTAL", "200", "XLX6", "second-key", "second attempt")); + assertEquals("RL-SECOND", result.relabelNo()); + verify(relabelItems).insert(any(RelabelItem.class)); + verify(cartons).updateById(carton); } @Test @@ -444,7 +448,7 @@ class ReturnReceiptServiceTest { } @Test - void parcelWithoutTrackingGeneratesParcelCodeAndNeverReturnsBoxLabelsOnReplay() { + void parcelWithoutTrackingKeepsPhysicalMarkOnReplay() { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 3, 0); ReturnOrderItem line = item(11L, "SKU-A", 3, 0); stubNewReceipt(order, List.of(line), "SP-SOURCE"); @@ -455,7 +459,7 @@ class ReturnReceiptServiceTest { var capture = ArgumentCaptor.forClass(ReturnReceipt.class); verify(receipts).insert(capture.capture()); assertThat(result.unitCode()).startsWith("PK"); - assertThat(result.cartonMark()).isNull(); + assertThat(result.cartonMark()).isEqualTo("SP-SOURCE"); assertThat(result.printableHtml()).isNull(); assertThat(result.labels()).isEmpty(); assertThat(capture.getValue().getTrackingNo()).isNull(); @@ -463,7 +467,7 @@ class ReturnReceiptServiceTest { var replay = service.confirm(identity, request); assertThat(replay.unitCode()).isEqualTo(result.unitCode()); assertThat(replay.labels()).isEmpty(); - verify(inventory, never()).label(anyString(), anyString()); + verify(inventory, times(2)).label("customer-1", "SP-SOURCE"); verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any()); } diff --git a/scripts/README.md b/scripts/README.md index 5b084247c..43bf3d506 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -497,3 +497,5 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e `f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="" --env-file="" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。 + +- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。 diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 3215f965f..0a65a9557 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -5054,3 +5054,28 @@ SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistic PREPARE return_sku_index_stmt FROM @return_sku_index_ddl; EXECUTE return_sku_index_stmt; DEALLOCATE PREPARE return_sku_index_stmt; + +-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。 +-- 可重复执行,目标库 baiyue_lastleg。 +USE baiyue_lastleg; + +-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。 +-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND constraint_name='chk_ll_return_outbound_movement_delta'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', + 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND index_name='uk_ll_return_outbound_carton_sku'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/scripts/db/return-package-packing-boundary-incremental-20261010.sql b/scripts/db/return-package-packing-boundary-incremental-20261010.sql new file mode 100644 index 000000000..90b99d1d0 --- /dev/null +++ b/scripts/db/return-package-packing-boundary-incremental-20261010.sql @@ -0,0 +1,24 @@ +-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。 +-- 可重复执行,目标库 baiyue_lastleg。 +USE baiyue_lastleg; + +-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。 +-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND constraint_name='chk_ll_return_outbound_movement_delta'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', + 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND index_name='uk_ll_return_outbound_carton_sku'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; From 3cb4a5676a6369ca6b3142e224810819d380d779 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 11:44:58 +0800 Subject: [PATCH 02/80] fix(firstleg): use a single cabinet business number --- deploy/sql/头程数据库统一增量-MySQL8.sql | 4 +- .../firstleg/dto/ContainerOverviewDtos.java | 2 +- .../baiyue/firstleg/dto/SmartLoadingDtos.java | 2 +- .../entity/ContainerBookingEntity.java | 1 - .../mapper/SmartLoadingCandidateMapper.java | 2 +- .../dto/ArrivalCalendarEntry.java | 2 +- .../mapper/PortScheduleMapper.java | 2 +- .../service/ContainerOverviewService.java | 2 +- .../firstleg/service/ContainerService.java | 2 +- .../FirstlegDocumentNumberGenerator.java | 2 +- .../dto/PickupCalendarEntry.java | 2 +- .../mapper/TerminalDrayageMapper.java | 2 +- .../TerminalDrayageCalendarService.java | 2 +- .../db/phase3-channel-container-migration.sql | 2 +- .../db/phase3-smart-loading-schema.sql | 8 ++-- .../openapi/arrival-calendar-v1.yaml | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 6 +-- .../openapi/phase3-container-overview-v1.yaml | 2 +- .../openapi/terminal-drayage-v1.yaml | 2 +- .../ContainerControllerContractTest.java | 2 +- .../mapper/ArrivalCalendarMapperTest.java | 37 +++++++++++++++++++ .../service/ContainerServiceTest.java | 4 +- .../FirstlegDocumentNumberGeneratorTest.java | 2 +- .../service/SmartLoadingServiceTest.java | 2 +- .../TaillegRealProducerAcceptanceTest.java | 6 +-- .../TerminalDrayageCalendarMapperTest.java | 5 ++- .../TerminalDrayageCalendarServiceTest.java | 2 +- 27 files changed, 70 insertions(+), 39 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/ArrivalCalendarMapperTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cd4b64281..340c8c727 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1422,7 +1422,6 @@ CREATE TABLE fl_consumed_event ( CREATE TABLE fl_container_booking ( id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)', - booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号', container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订', cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号', source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', @@ -1477,7 +1476,6 @@ CREATE TABLE fl_container_booking ( updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间', version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_container_booking_no (booking_no), UNIQUE KEY uk_container_booking_cabinet (cabinet_no), UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no), KEY idx_container_booking_container_no (container_no,updated_at,id), @@ -5337,7 +5335,7 @@ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container SELECT MAX(plan_status) INTO current_plan_status FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id; IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( - NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + NOT (OLD.id <=> NEW.id) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_group_code <=> NEW.channel_group_code) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 0a742df39..5a892a742 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -13,7 +13,7 @@ public final class ContainerOverviewDtos { public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda, List documents, ExceptionState exception, List exceptionHistory, List timeline) {} - public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo, + public record ContainerBookingEntityView(Long id, String cabinetNo, String containerNo, String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName, String departurePortCode, String departurePortName, String destinationPortCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 7374c753a..7b9e6f6f4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -48,7 +48,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} if (size == 0) size = 20; } } - public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo, + public record WorkbenchContainer(Long bookingId, String cabinetNo, String containerNo, Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, Long planId, String planNo, String planStatus, Integer version) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index fd19f6105..9c3829cbd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -19,7 +19,6 @@ import java.time.LocalDateTime; @TableName("fl_container_booking") public class ContainerBookingEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; - private String bookingNo; private String containerNo; private String cabinetNo; private String sourceType; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index fbf5a11c9..22a75c851 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -335,7 +335,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba @Select(""" ") List findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " + - "AND count_status='CONFIRMED' AND status IN ('AVAILABLE','RELABEL_PENDING','PACKING_RESERVED') ORDER BY created_at DESC,id DESC LIMIT 500") + "AND count_status='CONFIRMED' AND status IN ('AVAILABLE','RELABEL_PENDING','PACKING_RESERVED','MERGED','OUTBOUNDED','OUTBOUND_PENDING','DISCARD_PENDING') ORDER BY created_at DESC,id DESC LIMIT 500") List findRelabelCandidates(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1") ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark); 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 415ccb48f..e3ce20ed2 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 @@ -444,9 +444,8 @@ public class ReturnReceiptService { // 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()); if (!"RETURN_RELABEL".equals(tail.getOrderType())) { + tail.setSubOrderStatus("RETURN_RELABEL:PENDING_REVIEW"); tail.setPackageCount(Math.toIntExact(totalQuantity)); tail.setProductQuantity(Math.toIntExact(totalQuantity)); } 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 d6f67a0ec..b2baf571c 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 @@ -66,6 +66,26 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class RelabelApplicationServiceTest { + @Test void returnInventoryCandidateUsesRealProductNames() { + var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + var lineMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); + ReflectionTestUtils.setField(service, "returnCartons", cartonMapper); + ReflectionTestUtils.setField(service, "returnCartonItems", lineMapper); + ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class)); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + carton.setId(20L); carton.setStatus("AVAILABLE"); carton.setSourceUnitType("BOX"); carton.setTotalQuantity(4L); + var first = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); + first.setSkuCode("A"); first.setProductNameZh(" 中文商品甲 "); first.setQuantity(2L); + var second = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); + second.setSkuCode("B"); second.setProductNameZh("中文商品乙"); second.setQuantity(2L); + when(cartonMapper.findRelabelCandidates("C", "W")).thenReturn(List.of(carton)); + when(lineMapper.listCarton(20L)).thenReturn(List.of(first, second)); + var candidate = service.candidates("C", "W", null).get(0); + assertEquals("中文商品甲、中文商品乙", candidate.productNameZh()); + assertEquals(4L, candidate.stockQuantity()); + assertEquals("AVAILABLE", candidate.inventoryStatus()); + assertEquals(List.of(), candidate.mergedIntoCartonMarks()); + } @Mock RelabelApplicationMapper applications; @Mock RelabelItemMapper items; @Mock RelabelStatusHistoryMapper histories; @@ -554,9 +574,11 @@ class RelabelApplicationServiceTest { RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); application.setLastlegOrderId(21L); application.setFulfillmentStatus("PRE_DISPATCH_CREATED"); - LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING"); + LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "INBOUND_COMPLETED"); order.setOrderType("RETURN_RELABEL"); order.setRelabelOrderNo("FBR-1"); + order.setPackageCount(7); order.setProductQuantity(110); + order.setSubOrderStatus("RETURN_RELABEL:COMPLETED"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); when(orders.selectById(21L)).thenReturn(order); @@ -568,6 +590,31 @@ class RelabelApplicationServiceTest { assertEquals("OUTBOUNDED", application.getFulfillmentStatus()); assertEquals("INBOUND_COMPLETED", order.getCurrentStatus()); assertEquals("COMPLETED", order.getRelabelStatus()); + assertEquals(7, order.getPackageCount()); + assertEquals(110, order.getProductQuantity()); + assertEquals("RETURN_RELABEL:COMPLETED", order.getSubOrderStatus()); + } + + @Test void partialReturnRelabelCreationPreservesParentReceiptQuantitiesAndStatus() { + LastlegOrder parent = order(21L, "10001", "RT-ORIGINAL", "INBOUND_COMPLETED"); + parent.setOrderType("RETURN_RELABEL"); parent.setSourceOrderNo("RT-ORIGINAL"); + parent.setPackageCount(7); parent.setReceivedPackageCount(7); parent.setProductQuantity(110); + parent.setSubOrderStatus("RETURN_RELABEL:COMPLETED"); + when(orders.selectById(21L)).thenReturn(parent); + ReflectionTestUtils.invokeMethod(service, "linkExistingTailOrder", "10001", 21L, "RL-PARTIAL", 1, LocalDateTime.now()); + assertEquals(7, parent.getPackageCount()); assertEquals(7, parent.getReceivedPackageCount()); + assertEquals(110, parent.getProductQuantity()); + assertEquals("RT-ORIGINAL", parent.getSourceOrderNo()); + assertEquals("INBOUND_COMPLETED", parent.getCurrentStatus()); + assertEquals("RETURN_RELABEL:COMPLETED", parent.getSubOrderStatus()); + assertEquals("RL-PARTIAL", parent.getRelabelOrderNo()); + when(orders.selectOne(any())).thenReturn(parent); + var request = org.mockito.Mockito.mock(RelabelRequests.Create.class); + when(request.customerOrderNo()).thenReturn("RT-ORIGINAL"); + ReflectionTestUtils.invokeMethod(service, "insertTailOrder", "10001", request, "hash", "RL-SECOND", "W", 1, LocalDateTime.now()); + assertEquals(7, parent.getPackageCount()); assertEquals(110, parent.getProductQuantity()); + assertEquals("RETURN_RELABEL:COMPLETED", parent.getSubOrderStatus()); + assertEquals("RL-SECOND", parent.getRelabelOrderNo()); } @Test @@ -1013,6 +1060,7 @@ class RelabelApplicationServiceTest { var lineMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); ReflectionTestUtils.setField(service, "returnCartons", cartonMapper); ReflectionTestUtils.setField(service, "returnCartonItems", lineMapper); + ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class)); var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1"); carton.setCartonMark("RETURN-BOX-1"); carton.setStatus("AVAILABLE"); carton.setSourceUnitType("BOX"); 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 340a2221f..6349a9bd5 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 @@ -28,6 +28,7 @@ class RelabelReturnCartonCandidateTest { ReflectionTestUtils.setField(service, "inventoryPositions", positions); ReflectionTestUtils.setField(service, "returnCartons", cartons); ReflectionTestUtils.setField(service, "returnCartonItems", cartonItems); + ReflectionTestUtils.setField(service, "returnPackingTraces", mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class)); ReturnInventoryCarton carton = new ReturnInventoryCarton(); carton.setId(9001L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1"); diff --git a/scripts/db/return-consolidated-incremental-20261012.sql b/scripts/db/return-consolidated-incremental-20261012.sql new file mode 100644 index 000000000..0469fef9d --- /dev/null +++ b/scripts/db/return-consolidated-incremental-20261012.sql @@ -0,0 +1,113 @@ +-- 退货近期增量汇总(2026-10-12),MySQL 8.0.16+,可重复执行。 +-- 连接到需要升级的 MySQL 实例后,整份执行;目标仅 baiyue_lastleg。 +-- USE 指定数据库,不指定服务器;本脚本不创建数据库,不写测试单据,不修改库存/清点事实。 +-- 前提:已有尾程和退货合箱/出库基线表(不是新库初始化脚本)。 +-- 汇总10月10日/12日直接退货增量,并补齐清点、事实数量及多次换标申请结构。 +-- 排车车队/IAM权限为独立业务基线,不纳入本退货脚本。 +-- PARCEL三类型换标、RETURN_PACK/RETURN_COMPLETION_TRANSFER历史以及清点续作均复用现有结构。 +-- DDL隐式提交;执行客户端遇错须停止,不使用 --force。可重复执行不等于整个脚本事务回滚。 +USE `baiyue_lastleg`; +SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port, VERSION() AS mysql_version; + +-- 一、件数出库快照、部分扣件守恒、同箱分批出库索引。 +-- 退货单 SKU 按件数出库快照。目标库:baiyue_lastleg。可重复执行。 +USE baiyue_lastleg; +SET @return_sku_quantity_ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity'), + 'SELECT 1', + 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN return_sku_quantity BIGINT NULL COMMENT ''退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'' AFTER sku_code' +); +PREPARE return_sku_quantity_stmt FROM @return_sku_quantity_ddl; +EXECUTE return_sku_quantity_stmt; +DEALLOCATE PREPARE return_sku_quantity_stmt; + +-- 部分出库仅扣件数;整箱扣一箱,保留守恒和非负约束。 +SET @return_sku_check_ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND constraint_name='chk_ll_return_outbound_movement_delta'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)'); +PREPARE return_sku_check_stmt FROM @return_sku_check_ddl; +EXECUTE return_sku_check_stmt; +DEALLOCATE PREPARE return_sku_check_stmt; + +-- 二、标签状态(旧记录保留NULL,应用按既有兼容规则处理)。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 三、清点结构;不将历史已签收箱自动回退为待清点。 +-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。 +USE baiyue_lastleg; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)'); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; + +-- MySQL 8,本地尾程库,可重复执行;不修改现有清点数量或自动释放未完成任务。 +USE baiyue_lastleg; +-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。 +CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( + operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识', + warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识', + receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL', + started_at DATETIME(3) NULL COMMENT '本次开始清点时间', + created_at DATETIME(3) NOT NULL COMMENT '会话创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间', + PRIMARY KEY(operator_id), + UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) +) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; + +-- 四、换标事实数量与一单多申请;保留幂等唯一键。 +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 @relabel_has_unique = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'uk_ll_relabel_application_order'); +SET @relabel_has_index = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order'); +SET @relabel_index_sql = CASE + WHEN @relabel_has_unique > 0 AND @relabel_has_index = 0 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order, ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)' + WHEN @relabel_has_unique > 0 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order' + WHEN @relabel_has_index = 0 THEN + 'ALTER TABLE ll_relabel_application ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)' + ELSE 'SELECT 1' END; +PREPARE relabel_index_migration FROM @relabel_index_sql; +EXECUTE relabel_index_migration; +DEALLOCATE PREPARE relabel_index_migration; + +-- 同箱分批索引:同时已有旧/新索引或旧索引已删除时也可执行。 +SET @return_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='uk_ll_return_outbound_carton_sku'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku', 'SELECT 1'); +PREPARE return_index_stmt FROM @return_index_ddl; EXECUTE return_index_stmt; DEALLOCATE PREPARE return_index_stmt; +SET @return_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='idx_ll_return_outbound_carton_sku'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_outbound_movement ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)'); +PREPARE return_index_stmt FROM @return_index_ddl; EXECUTE return_index_stmt; DEALLOCATE PREPARE return_index_stmt; + +-- 校验输出:核对新增字段、索引和守恒约束。 +SELECT table_name,column_name,column_type,is_nullable,column_default FROM information_schema.columns +WHERE table_schema=DATABASE() AND ( +(table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity') OR +(table_name='ll_return_inventory_carton' AND column_name IN('label_status','count_status','counted_at','counted_by')) OR +(table_name='ll_relabel_pda_unit_fact' AND column_name='quantity')) ORDER BY table_name,column_name; +SELECT table_name,index_name,non_unique,GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns +FROM information_schema.statistics WHERE table_schema=DATABASE() AND index_name IN +('idx_ll_return_outbound_carton_sku','uk_ll_return_outbound_pick_sku','idx_ll_relabel_application_order','uk_ll_return_count_session_receipt','idx_ll_return_count') GROUP BY table_name,index_name,non_unique; +SELECT constraint_name,check_clause FROM information_schema.check_constraints WHERE constraint_schema=DATABASE() AND constraint_name='chk_ll_return_outbound_movement_delta'; \ No newline at end of file From c06e788b0fd140d3bd77b8bf481d23cce28a7a62 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 16:09:21 +0800 Subject: [PATCH 06/80] fix: complete single-order acceptance and add Lingxing WMS integration --- integration-service/.env.example | 3 + .../config/LingxingWmsProperties.java | 49 +++ .../LingxingInternalController.java | 55 ++++ .../dto/lingxing/LingxingWmsDtos.java | 25 ++ .../provider/lingxing/LingxingWmsClient.java | 263 ++++++++++++++++ .../lingxing/LingxingWmsException.java | 24 ++ .../provider/lingxing/LingxingWmsSigner.java | 74 +++++ .../resources/application-lingxing-local.yml | 68 +++++ .../src/main/resources/application.yml | 8 + .../resources/openapi/lingxing-wms-v1.yaml | 81 +++++ .../LingxingInternalControllerTest.java | 100 +++++++ .../lingxing/LingxingWmsClientTest.java | 261 ++++++++++++++++ .../lingxing/LingxingWmsSignerTest.java | 91 ++++++ scripts/prod-release-acceptance.mjs | 59 ++-- scripts/prod-release-acceptance.test.mjs | 44 ++- scripts/prod-release-delivery-stage.mjs | 14 +- scripts/prod-release-delivery-stage.test.mjs | 48 ++- scripts/prod-release-domestic-stage.mjs | 231 ++++++++++++++ scripts/prod-release-domestic-stage.test.mjs | 122 ++++++++ scripts/prod-release-full-flow.mjs | 93 ++++++ scripts/prod-release-http.mjs | 123 ++++++++ scripts/prod-release-http.test.mjs | 50 ++++ scripts/prod-release-measurement-plan.mjs | 4 +- scripts/prod-release-order-stage.mjs | 5 +- scripts/prod-release-order-stage.test.mjs | 15 +- scripts/prod-release-overseas-stage.mjs | 283 ++++++++++++++++++ scripts/prod-release-overseas-stage.test.mjs | 158 ++++++++++ 27 files changed, 2305 insertions(+), 46 deletions(-) create mode 100644 integration-service/.env.example create mode 100644 integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java create mode 100644 integration-service/src/main/resources/application-lingxing-local.yml create mode 100644 integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml create mode 100644 integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java create mode 100644 scripts/prod-release-domestic-stage.mjs create mode 100644 scripts/prod-release-domestic-stage.test.mjs create mode 100644 scripts/prod-release-full-flow.mjs create mode 100644 scripts/prod-release-http.mjs create mode 100644 scripts/prod-release-http.test.mjs create mode 100644 scripts/prod-release-overseas-stage.mjs create mode 100644 scripts/prod-release-overseas-stage.test.mjs diff --git a/integration-service/.env.example b/integration-service/.env.example new file mode 100644 index 000000000..5a8b20f6e --- /dev/null +++ b/integration-service/.env.example @@ -0,0 +1,3 @@ +# Copy to .env.local for local WMS connectivity verification. +LINGXING_WMS_APP_KEY= +LINGXING_WMS_APP_SECRET= diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java new file mode 100644 index 000000000..5226455a5 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java @@ -0,0 +1,49 @@ +package com.baiyue.integration.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import java.net.URI; +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms") +public record LingxingWmsProperties( + @DefaultValue("false") boolean enabled, + @DefaultValue("https://api.xlwms.com") URI baseUri, + String appKey, + String appSecret, + @DefaultValue("5s") Duration connectTimeout, + @DefaultValue("15s") Duration requestTimeout, + @DefaultValue("1s") Duration minRequestInterval +) { + public LingxingWmsProperties { + baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri; + connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout; + requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout; + minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval; + if (connectTimeout.isZero() || connectTimeout.isNegative() + || requestTimeout.isZero() || requestTimeout.isNegative() + || minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) { + throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间"); + } + String host = baseUri.getHost(); + boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) + || "[::1]".equals(host); + boolean secureScheme = "https".equalsIgnoreCase(baseUri.getScheme()) + || ("http".equalsIgnoreCase(baseUri.getScheme()) && loopback); + if (host == null || !secureScheme || baseUri.getUserInfo() != null + || baseUri.getQuery() != null || baseUri.getFragment() != null + || (!baseUri.getPath().isEmpty() && !"/".equals(baseUri.getPath()))) { + throw new IllegalArgumentException("领星 WMS base-uri 必须是 HTTPS 源地址;仅本机测试允许 HTTP"); + } + } + + public boolean hasCredentials() { + return appKey != null && !appKey.isBlank() && appSecret != null && !appSecret.isBlank(); + } + + @Override + public String toString() { + return "LingxingWmsProperties[enabled=" + enabled + ", credentialsConfigured=" + hasCredentials() + "]"; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java new file mode 100644 index 000000000..6d57af5df --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java @@ -0,0 +1,55 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.baiyue.integration.provider.lingxing.LingxingWmsClient; +import com.baiyue.integration.provider.lingxing.LingxingWmsException; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "领星 WMS 内部查询", description = "仅内部服务令牌认证,不提供公网路由") +@RestController +@RequestMapping("/internal/integrations/lingxing/wms") +public class LingxingInternalController { + private final InternalServiceTokenGuard tokenGuard; + private final LingxingWmsClient client; + + public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) { + this.tokenGuard = tokenGuard; + this.client = client; + } + + @Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要") + @PostMapping("/outbound-orders/query") + public ApiResponse query( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody QueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryPage(request)); + } + + @ExceptionHandler(LingxingWmsException.class) + public ApiResponse handleProviderFailure(LingxingWmsException exception, + HttpServletRequest request) { + ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR; + return ApiResponse.of(code, exception.getMessage(), + new ProviderFailure(exception.code(), exception.retryable(), + exception.upstreamStatus(), exception.sourceCode()), + CorrelationContext.requestId(request), CorrelationContext.traceId(request)); + } + + public record ProviderFailure(String errorCode, boolean retryable, + Integer upstreamStatus, Integer sourceCode) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java new file mode 100644 index 000000000..12ddac758 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -0,0 +1,25 @@ +package com.baiyue.integration.dto.lingxing; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +public final class LingxingWmsDtos { + private LingxingWmsDtos() { } + + public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime, + @Min(1) int current, @Min(1) @Max(100) int size) { } + + // Preserve source time strings until the account's source timezone has been verified. + public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode, + Integer status, String createTime, String outboundTime) { } + + public record PageResult(List records, Long total, int current, int size, + int sourceCode, Instant observedAt) { + public PageResult { records = List.copyOf(records); } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java new file mode 100644 index 000000000..01a27a7c6 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -0,0 +1,263 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Semaphore; + +@Component +public final class LingxingWmsClient { + private static final String PAGE_PATH = "/openapi/v2/order/delivery/page"; + private static final Set AUTH_ERROR_CODES = Set.of(11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001); + private static final DateTimeFormatter SOURCE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final LingxingWmsProperties properties; + private final ObjectMapper mapper; + private final LingxingWmsSigner signer; + private final HttpClient httpClient; + private final Clock clock; + private final Semaphore concurrency = new Semaphore(1); + private long lastRequestNanos; + private boolean hasRequested; + + @Autowired + public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) { + this(properties, mapper, Clock.systemUTC()); + } + + LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) { + this.properties = properties; + this.mapper = mapper; + this.clock = clock; + signer = new LingxingWmsSigner(mapper); + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER).build(); + } + + public PageResult queryPage(QueryRequest request) { + if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false); + if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false); + validate(request); + if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true); + try { + reserveRequest(); + Map data = new LinkedHashMap<>(); + data.put("current", request.current()); + data.put("size", request.size()); + data.put("timeType", "outboundTime"); + data.put("startTime", SOURCE_TIME.format(request.startTime())); + data.put("endTime", SOURCE_TIME.format(request.endTime())); + data.put("status", 100); + var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant()); + HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH)) + .timeout(properties.requestTimeout()).header("Accept", "application/json") + .header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build(); + HttpResponse response = send(httpRequest); + validateStatus(response.statusCode()); + return page(response.body(), request); + } finally { + concurrency.release(); + } + } + + private void validate(QueryRequest request) { + if (request == null || request.startTime() == null || request.endTime() == null + || !request.startTime().truncatedTo(ChronoUnit.SECONDS).isBefore(request.endTime().truncatedTo(ChronoUnit.SECONDS)) + || request.startTime().isBefore(request.endTime().minusMonths(1)) + || request.current() < 1 || request.size() < 1 || request.size() > 100) { + // These are local request bounds, not a claim about the supplier's pagination limits. + throw failure("INVALID_REQUEST", "领星 WMS 查询范围最多一个自然月,每页条数须为 1 至 100", false); + } + } + + private void reserveRequest() { + long now = System.nanoTime(); + if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) { + throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true); + } + hasRequested = true; + lastRequestNanos = now; + } + + private HttpResponse send(HttpRequest request) { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (HttpTimeoutException exception) { + throw failure("TIMEOUT", "领星 WMS 请求超时", true); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw failure("INTERRUPTED", "领星 WMS 请求被中断", true); + } catch (IOException exception) { + throw failure("IO_FAILED", "领星 WMS 网络请求失败", true); + } + } + + private void validateStatus(int status) { + if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null); + if (status == 401 || status == 403) { + throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null); + } + if (status != 200) { + throw upstreamFailure("HTTP_FAILED", "领星 WMS 返回异常 HTTP 状态", status >= 500, status, null); + } + } + + private PageResult page(String body, QueryRequest request) { + JsonNode root; + try { + root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body); + } catch (JsonProcessingException exception) { + throw invalidResponse("JSON", null); + } + if (root == null || !root.isObject()) throw invalidResponse("ROOT_TYPE_" + nodeType(root), null); + JsonNode sourceCode = root.get("code"); + // Official OMP ErrorResponse is distinct from its integer-code business response. + if (sourceCode != null && sourceCode.isTextual()) { + Integer errorCode = diagnosticCode(sourceCode); + if (errorCode != null && AUTH_ERROR_CODES.contains(errorCode)) throw sourceRejected(errorCode); + throw invalidResponse("CODE_TYPE_STRING", errorCode); + } + if (sourceCode == null || !sourceCode.isIntegralNumber() || !sourceCode.canConvertToInt()) { + throw invalidResponse("CODE_TYPE_" + nodeType(sourceCode), diagnosticCode(sourceCode)); + } + if (sourceCode.intValue() != 200) { + throw sourceRejected(sourceCode.intValue()); + } + JsonNode success = root.get("success"); + // OMP marks success required, but the verified WMS response omits it; validate it when present. + if (success != null) { + if (!success.isBoolean()) throw invalidResponse("SUCCESS_TYPE_" + nodeType(success), 200); + if (!success.booleanValue()) throw sourceRejected(200); + } + JsonNode data = root.get("data"); + if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200); + JsonNode records = data.get("records"); + if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200); + if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); + Long total = optionalTotal(data.get("total")); + if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200); + verifyPage(data.get("current"), request.current(), "CURRENT"); + verifyPage(data.get("size"), request.size(), "SIZE"); + List summaries = new ArrayList<>(); + for (JsonNode record : records) { + if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200); + String deliveryNo = optionalText(record, "deliveryNo"); + if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200); + summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"), + optionalText(record, "whCode"), optionalStatus(record.get("status")), + optionalText(record, "createTime"), optionalText(record, "outboundTime"))); + } + return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant()); + } + + private static String optionalText(JsonNode record, String field) { + JsonNode value = record.get(field); + if (value == null || value.isNull()) return null; + if (!value.isTextual()) { + String stage = switch (field) { + case "deliveryNo" -> "DELIVERY_NO"; + case "sourceNo" -> "SOURCE_NO"; + case "customerCode" -> "CUSTOMER_CODE"; + case "whCode" -> "WAREHOUSE_CODE"; + case "createTime" -> "CREATE_TIME"; + case "outboundTime" -> "OUTBOUND_TIME"; + default -> "TEXT_FIELD"; + }; + throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); + } + return value.textValue(); + } + + private static Integer optionalStatus(JsonNode value) { + if (missingNumber(value)) return null; + if (!value.isIntegralNumber() || !value.canConvertToInt()) { + throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200); + } + return value.intValue(); + } + + private static Long optionalTotal(JsonNode value) { + if (missingNumber(value)) return null; + if (!value.isIntegralNumber() || !value.canConvertToLong()) { + throw invalidResponse("TOTAL_TYPE_" + nodeType(value), 200); + } + if (value.longValue() < 0) throw invalidResponse("TOTAL_NEGATIVE", 200); + return value.longValue(); + } + + private static void verifyPage(JsonNode value, int expected, String stage) { + if (missingNumber(value)) return; + if (!value.isIntegralNumber() || !value.canConvertToInt()) { + throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); + } + if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200); + } + + private static Integer diagnosticCode(JsonNode value) { + if (value == null) return null; + if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue(); + // Only the documented authentication error envelope accepts string error codes. + if (!value.isTextual() || !value.textValue().matches("[0-9]{1,10}")) return null; + try { + return Integer.valueOf(value.textValue()); + } catch (NumberFormatException exception) { + return null; + } + } + + private static String nodeType(JsonNode value) { + return value == null ? "MISSING" : value.getNodeType().name(); + } + + private static boolean missingNumber(JsonNode value) { + // OMP explicitly serializes null numeric fields as an empty string or null. + return value == null || value.isNull() || (value.isTextual() && value.textValue().isEmpty()); + } + + private static LingxingWmsException sourceRejected(int code) { + if (code == 11008) { + return upstreamFailure("AUTHORIZATION_REJECTED", + "领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code); + } + if (code == 11006) { + return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code); + } + return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code); + } + + private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) { + return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")", + false, 200, sourceCode); + } + + private static LingxingWmsException failure(String code, String message, boolean retryable) { + return upstreamFailure(code, message, retryable, null, null); + } + + private static LingxingWmsException upstreamFailure(String code, String message, boolean retryable, + Integer status, Integer sourceCode) { + return new LingxingWmsException("LINGXING_WMS_" + code, message, retryable, status, sourceCode); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java new file mode 100644 index 000000000..d75bf0cb8 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java @@ -0,0 +1,24 @@ +package com.baiyue.integration.provider.lingxing; + +/** Only fixed messages and numeric upstream metadata may leave the adapter. */ +public final class LingxingWmsException extends RuntimeException { + private final String code; + private final boolean retryable; + private final Integer upstreamStatus; + private final Integer sourceCode; + + public LingxingWmsException(String code, String message, boolean retryable, + Integer upstreamStatus, Integer sourceCode) { + // Upstream exceptions can contain signed URLs or response fragments; never attach them. + super(message); + this.code = code; + this.retryable = retryable; + this.upstreamStatus = upstreamStatus; + this.sourceCode = sourceCode; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + public Integer upstreamStatus() { return upstreamStatus; } + public Integer sourceCode() { return sourceCode; } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java new file mode 100644 index 000000000..7293fdf1e --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java @@ -0,0 +1,74 @@ +package com.baiyue.integration.provider.lingxing; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; +import java.util.StringJoiner; + +/** OMP service-provider signature; the data object keeps its transmitted insertion order. */ +public final class LingxingWmsSigner { + private final ObjectMapper mapper; + private final ObjectWriter writer; + + public LingxingWmsSigner(ObjectMapper mapper) { + this.mapper = mapper; + writer = mapper.writer().without(SerializationFeature.INDENT_OUTPUT, SerializationFeature.WRAP_ROOT_VALUE); + } + + public SignedRequest sign(String appKey, String appSecret, String path, Map data, Instant requestedAt) { + if (appKey == null || appKey.isBlank() || appSecret == null || appSecret.isBlank() + || path == null || !path.startsWith("/openapi/v2/") || path.contains("?") + || data == null || requestedAt == null) throw failure(); + try { + JsonNode dataNode = mapper.valueToTree(data); + String timestamp = Long.toString(requestedAt.getEpochSecond()); + // Official OMP sample uses Map/List string form, not JSON, and includes parameter names. + String input = appSecret + path + "appKey" + appKey + "data" + parameterValue(dataNode) + + "timestamp" + timestamp + appSecret; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String signature = HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + ObjectNode body = mapper.createObjectNode(); + body.put("appKey", appKey); + body.set("data", dataNode); + body.put("timestamp", timestamp); + body.put("sign", signature); + return new SignedRequest(writer.writeValueAsString(body)); + } catch (JsonProcessingException | GeneralSecurityException | IllegalArgumentException exception) { + throw failure(); + } + } + + private static String parameterValue(JsonNode value) { + if (value.isObject()) { + StringJoiner rendered = new StringJoiner(", ", "{", "}"); + value.fields().forEachRemaining(field -> rendered.add(field.getKey() + "=" + parameterValue(field.getValue()))); + return rendered.toString(); + } + if (value.isArray()) { + StringJoiner rendered = new StringJoiner(", ", "[", "]"); + value.forEach(child -> rendered.add(parameterValue(child))); + return rendered.toString(); + } + return value.isNull() ? "null" : value.asText(); + } + + private static LingxingWmsException failure() { + return new LingxingWmsException("LINGXING_WMS_SIGNATURE_ERROR", "领星 WMS 请求签名失败", false, null, null); + } + + public record SignedRequest(String body) { + @Override public String toString() { return "SignedRequest[REDACTED]"; } + } +} diff --git a/integration-service/src/main/resources/application-lingxing-local.yml b/integration-service/src/main/resources/application-lingxing-local.yml new file mode 100644 index 000000000..9fae3d231 --- /dev/null +++ b/integration-service/src/main/resources/application-lingxing-local.yml @@ -0,0 +1,68 @@ +# Select explicitly with --spring.config.location=classpath:/application-lingxing-local.yml. +# No remote imports, database, registry or scheduled jobs are used by this local instance. +server: + address: 127.0.0.1 + port: ${INTEGRATION_LOCAL_PORT:19210} + +spring: + application: + name: integration-service + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration + cloud: + bootstrap: + enabled: false + discovery: + enabled: false + service-registry: + auto-registration: + enabled: false + nacos: + config: + enabled: false + import-check: + enabled: false + discovery: + enabled: false + register-enabled: false + +baiyue: + runtime: + scheduling-enabled: false + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + integration: + lingxing-wms: + enabled: ${LINGXING_WMS_ENABLED:false} + base-uri: https://api.xlwms.com + app-key: ${LINGXING_WMS_APP_KEY:} + app-secret: ${LINGXING_WMS_APP_SECRET:} + connect-timeout: 5s + request-timeout: 15s + min-request-interval: 1s + baidu-ocr: + enabled: false + amazon-sp-api: + enabled: false + carrier-schedule: + matson: + enabled: false + hede: + enabled: false + sml: + enabled: false + zim: + enabled: false + evergreen: + enabled: false + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 7916af7b8..336a6e74b 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -35,6 +35,14 @@ baiyue: security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} integration: + lingxing-wms: + enabled: ${LINGXING_WMS_ENABLED:false} + base-uri: https://api.xlwms.com + app-key: ${LINGXING_WMS_APP_KEY:} + app-secret: ${LINGXING_WMS_APP_SECRET:} + connect-timeout: 5s + request-timeout: 15s + min-request-interval: 1s baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} api-key: ${BAIDU_OCR_API_KEY:} diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml new file mode 100644 index 000000000..cb25a3309 --- /dev/null +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -0,0 +1,81 @@ +openapi: 3.0.3 +info: + title: Lingxing WMS Internal Read API + version: 1.0.0 + description: Read-only provider adapter. No business scheduling, persistence, inventory mutation or public Gateway route. +security: + - InternalServiceToken: [] +paths: + /internal/integrations/lingxing/wms/outbound-orders/query: + post: + summary: Query one page of completed WMS outbound summaries by outbound time + description: Calls OMP /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. Source time strings are preserved. This is a single page, not a complete sales report. It uses the self-developed-system API authorization in the WMS management console. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QueryRequest'} + responses: + '200': + description: Standard envelope; code 200 for success, A0401 for rejected internal identity, A0422 for bean validation, B0001 or S0503 for provider/configuration failures. Failures never return a successful empty page. + content: + application/json: + schema: {$ref: '#/components/schemas/Response'} +components: + securitySchemes: + InternalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + schemas: + QueryRequest: + type: object + required: [startTime, endTime, current, size] + properties: + startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime; must precede endTime and must not be earlier than endTime minus one calendar month. This is a local guard.} + endTime: {type: string, example: '2026-10-12T08:00:00', description: Source local datetime.} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 100, description: Local request cap; not a claim about the provider quota. Use 1 for connectivity verification.} + OrderSummary: + type: object + required: [deliveryNo] + properties: + deliveryNo: {type: string} + sourceNo: {type: string, nullable: true} + customerCode: {type: string, nullable: true} + whCode: {type: string, nullable: true} + status: {type: integer, nullable: true} + createTime: {type: string, nullable: true} + outboundTime: {type: string, nullable: true} + PageResult: + type: object + required: [records, current, size, sourceCode, observedAt] + properties: + records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}} + total: {type: string, pattern: '^[0-9]+$', nullable: true, description: Provider total only when supplied. Java Long follows the platform JSON string convention.} + current: {type: integer} + size: {type: integer} + sourceCode: {type: integer} + observedAt: {type: string, format: date-time} + ProviderFailure: + type: object + required: [errorCode, retryable] + properties: + errorCode: {type: string} + retryable: {type: boolean} + upstreamStatus: {type: integer, nullable: true} + sourceCode: {type: integer, nullable: true} + Response: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string} + message: {type: string, description: Sanitized local message; never the raw provider response.} + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/PageResult'} + - {$ref: '#/components/schemas/ProviderFailure'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java new file mode 100644 index 000000000..870b2a46b --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java @@ -0,0 +1,100 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.common.web.LongIdJacksonConfiguration; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.provider.lingxing.LingxingWmsClient; +import com.baiyue.integration.provider.lingxing.LingxingWmsException; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LingxingInternalControllerTest { + private static final String PATH = "/internal/integrations/lingxing/wms/outbound-orders/query"; + private static final String BODY = """ + {"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1} + """; + private final String token = UUID.randomUUID().toString(); + private LingxingWmsClient client; + private MockMvc mvc; + + @BeforeEach + void setUp() { + var guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token); + ReflectionTestUtils.invokeMethod(guard, "initializeToken"); + client = mock(LingxingWmsClient.class); + var builder = Jackson2ObjectMapperBuilder.json(); + try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) { + context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder); + } + mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client)) + .setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build())) + .setControllerAdvice(new GlobalExceptionHandler()).build(); + } + + @Test + void missingOrWrongInternalTokenCannotCallProvider() throws Exception { + mvc.perform(post(PATH).contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + mvc.perform(post(PATH).header("X-Internal-Service-Token", "wrong") + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + verifyNoInteractions(client); + } + + @Test + void authenticatedReadReturnsOnlyOutboundSummary() throws Exception { + when(client.queryPage(any())).thenReturn(new PageResult( + List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100, + "2026-10-01 08:00:00", "2026-10-02 08:00:00")), + 1L, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"))); + mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1")) + .andExpect(jsonPath("$.data.records[0].deliveryNo").value("TEST-OUT-001")) + .andExpect(jsonPath("$.data.records[0].recipient").doesNotExist()); + } + + @Test + void invalidPageCannotReachProvider() throws Exception { + mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422")); + verifyNoInteractions(client); + } + + @Test + void providerFailureRemainsAnExplicitBusinessFailure() throws Exception { + when(client.queryPage(any())).thenThrow(new LingxingWmsException( + "LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null)); + mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503")) + .andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED")) + .andExpect(jsonPath("$.data.upstreamStatus").value(429)) + .andExpect(jsonPath("$.data.records").doesNotExist()); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java new file mode 100644 index 000000000..aa634c59e --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -0,0 +1,261 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LingxingWmsClientTest { + private static final String APP_KEY = "local-test-app-key"; + private static final String APP_SECRET = "local-test-app-secret"; + private static final String PRIVATE_DATA = "customer-private@example.test"; + private static final String PATH = "/openapi/v2/order/delivery/page"; + private static final Instant NOW = Instant.parse("2026-10-12T00:00:00.987Z"); + private static final String EMPTY = "{\"code\":200,\"success\":true,\"data\":{\"total\":0,\"current\":1,\"size\":1,\"records\":[]}}"; + private final ObjectMapper mapper = new ObjectMapper(); + private final AtomicInteger calls = new AtomicInteger(); + private final AtomicInteger responseStatus = new AtomicInteger(200); + private final AtomicReference responseBody = new AtomicReference<>(EMPTY); + private final AtomicReference receivedBody = new AtomicReference<>(); + private final AtomicReference receivedUri = new AtomicReference<>(); + private final AtomicReference receivedMethod = new AtomicReference<>(); + private HttpServer server; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext(PATH, exchange -> { + calls.incrementAndGet(); + receivedMethod.set(exchange.getRequestMethod()); + receivedUri.set(exchange.getRequestURI()); + receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = responseBody.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(responseStatus.get(), bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + } + + @AfterEach + void stopServer() { server.stop(0); } + + @Test + void sendsOmpWmsSignatureAndOutboundTimeFilterAndOnlyReturnsWhitelist() throws Exception { + responseBody.set(""" + {"code":200,"success":true,"data":{"total":12,"current":1,"size":1,"records":[{ + "deliveryNo":"DO-LOCAL-001","sourceNo":"OB-LOCAL-001","customerCode":"C-LOCAL", + "whCode":"WH-LOCAL","status":100,"createTime":"2026-09-21 10:11:12", + "outboundTime":"2026-09-22 20:21:22","email":"customer-private@example.test", + "remark":"local-test-app-secret","receiver":"PRIVATE_RECEIVER" + }]}} + """); + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + + var body = mapper.readTree(receivedBody.get()); + String timestamp = Long.toString(NOW.getEpochSecond()); + String dataValue = "{current=1, size=1, timeType=outboundTime, startTime=2026-09-12 08:00:00, endTime=2026-10-12 08:00:00, status=100}"; + String input = APP_SECRET + PATH + "appKey" + APP_KEY + "data" + dataValue + "timestamp" + timestamp + APP_SECRET; + assertThat(body.path("sign").textValue()).isEqualTo(hmac(input)); + assertThat(body.path("timestamp").textValue()).isEqualTo(timestamp).matches("\\d{10}"); + assertThat(body.path("appKey").textValue()).isEqualTo(APP_KEY); + assertThat(body.path("data").toString()).isEqualTo("{\"current\":1,\"size\":1,\"timeType\":\"outboundTime\",\"startTime\":\"2026-09-12 08:00:00\",\"endTime\":\"2026-10-12 08:00:00\",\"status\":100}"); + assertThat(receivedUri.get().getPath()).isEqualTo(PATH); + assertThat(receivedUri.get().getRawQuery()).isNull(); + assertThat(receivedMethod.get()).isEqualTo("POST"); + assertThat(receivedBody.get()).doesNotContain(APP_SECRET, "authcode", "reqTime", "access_token", "pageSize"); + assertThat(result.total()).isEqualTo(12L); + assertThat(result.observedAt()).isEqualTo(NOW); + assertThat(result.sourceCode()).isEqualTo(200); + assertThat(result.records()).singleElement().satisfies(order -> { + assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001"); + assertThat(order.sourceNo()).isEqualTo("OB-LOCAL-001"); + assertThat(order.customerCode()).isEqualTo("C-LOCAL"); + assertThat(order.whCode()).isEqualTo("WH-LOCAL"); + assertThat(order.status()).isEqualTo(100); + assertThat(order.createTime()).isEqualTo("2026-09-21 10:11:12"); + assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22"); + }); + String summary = mapper.writeValueAsString(result.records().get(0)); + assertThat(mapper.readTree(summary).size()).isEqualTo(7); + assertThat(summary).doesNotContain(PRIVATE_DATA, APP_SECRET, "PRIVATE_RECEIVER"); + } + + @Test + void emptySuccessfulPageHasNoInventedOrder() { + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(calls).hasValue(1); + } + + @Test + void verifiedWmsEnvelopeWithoutSuccessRequiresValidPage() { + responseBody.set(""" + {"code":200,"data":{"total":1,"current":1,"size":1,"records":[{ + "deliveryNo":"DO-LOCAL-001","status":100,"outboundTime":"2026-09-22 20:21:22" + }]}} + """); + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + assertThat(result.sourceCode()).isEqualTo(200); + assertThat(result.total()).isEqualTo(1L); + assertThat(result.records()).singleElement().satisfies(order -> { + assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001"); + assertThat(order.status()).isEqualTo(100); + assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22"); + }); + assertThat(calls).hasValue(1); + } + + @Test + void documentedEmptyNumericValuesRemainUnknown() { + responseBody.set("{\"code\":200,\"success\":true,\"data\":{\"total\":\"\",\"records\":[{\"deliveryNo\":\"DO-LOCAL\",\"status\":\"\",\"outboundTime\":\"\"}]}}"); + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + assertThat(result.total()).isNull(); + assertThat(result.records().get(0).status()).isNull(); + assertThat(result.records().get(0).outboundTime()).isEmpty(); + } + + @Test + void disabledAndMissingCredentialsNeverSendRequest() { + assertFailure(client(false, APP_KEY, APP_SECRET, Duration.ZERO), "DISABLED", false); + assertFailure(client(true, "", APP_SECRET, Duration.ZERO), "MISSING_CREDENTIALS", false); + assertFailure(client(true, APP_KEY, " ", Duration.ZERO), "MISSING_CREDENTIALS", false); + assertThat(calls).hasValue(0); + } + + @ParameterizedTest + @ValueSource(ints = {11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001}) + void documentedStringAuthErrorsNeedNoDataAndDoNotEchoPayloadOrRetry(int sourceCode) { + responseBody.set("{\"code\":\"" + sourceCode + "\",\"msg\":\"" + APP_KEY + APP_SECRET + PRIVATE_DATA + "\"}"); + String expected = sourceCode == 11008 ? "AUTHORIZATION_REJECTED" + : sourceCode == 11006 ? "SIGNATURE_REJECTED" : "SOURCE_REJECTED"; + var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), expected, sourceCode == 31001); + assertThat(error.sourceCode()).isEqualTo(sourceCode); + assertThat(error.upstreamStatus()).isEqualTo(200); + assertThat(calls).hasValue(1); + } + + @Test + void falseBusinessSuccessIsNotAnEmptySuccess() { + responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}"); + assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false); + } + + @ParameterizedTest + @ValueSource(ints = {401, 403, 429, 500, 302}) + void httpFailuresDoNotEchoBodyOrRetry(int status) { + responseStatus.set(status); + responseBody.set(APP_KEY + APP_SECRET + PRIVATE_DATA); + String code = status == 429 ? "HTTP_RATE_LIMITED" : status == 401 || status == 403 ? "AUTHORIZATION_REJECTED" : "HTTP_FAILED"; + var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), code, status == 429 || status >= 500); + assertThat(error.upstreamStatus()).isEqualTo(status); + assertThat(calls).hasValue(1); + } + + @Test + void malformedResponsesExposeFixedContractStageOnly() { + String[][] scenarios = { + {"" + PRIVATE_DATA + "", "JSON"}, + {"[]", "ROOT_TYPE_ARRAY"}, + {"{}", "CODE_TYPE_MISSING"}, + {"{\"code\":\"200\"}", "CODE_TYPE_STRING"}, + {"{\"code\":\"99999\"}", "CODE_TYPE_STRING"}, + {"{\"code\":200}", "DATA_TYPE_MISSING"}, + {"{\"code\":200,\"data\":{}}", "RECORDS_TYPE_MISSING"}, + {"{\"code\":200,\"success\":null}", "SUCCESS_TYPE_NULL"}, + {"{\"code\":200,\"success\":\"true\"}", "SUCCESS_TYPE_STRING"}, + {"{\"code\":200,\"success\":true,\"data\":[]}", "DATA_TYPE_ARRAY"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":{}}}", "RECORDS_TYPE_OBJECT"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{},{}]}}", "RECORDS_OVER_PAGE_SIZE"}, + {"{\"code\":200,\"success\":true,\"data\":{\"total\":\"0\",\"records\":[]}}", "TOTAL_TYPE_STRING"}, + {"{\"code\":200,\"success\":true,\"data\":{\"current\":2,\"records\":[]}}", "CURRENT_MISMATCH"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":1}]}}", "DELIVERY_NO_TYPE_NUMBER"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":\"A\",\"status\":\"100\"}]}}", "STATUS_TYPE_STRING"} + }; + var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO); + for (String[] scenario : scenarios) { + responseBody.set(scenario[0]); + var error = assertFailure(client, "INVALID_RESPONSE", false); + assertThat(error.getMessage()).contains("(" + scenario[1] + ")"); + assertThat(error.upstreamStatus()).isEqualTo(200); + } + } + + @Test + void localRateLimitStopsSecondRequest() { + var client = client(true, APP_KEY, APP_SECRET, Duration.ofMinutes(1)); + client.queryPage(query()); + assertFailure(client, "LOCAL_RATE_LIMITED", true); + assertThat(calls).hasValue(1); + } + + @Test + void validatesLocalPaginationAndMonthBeforeSending() { + var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO); + var query = query(); + for (var invalid : new QueryRequest[] { + new QueryRequest(query.startTime(), query.endTime(), 0, 1), + new QueryRequest(query.startTime(), query.endTime(), 1, 101), + new QueryRequest(query.endTime(), query.startTime(), 1, 1), + new QueryRequest(query.startTime().minusSeconds(1), query.endTime(), 1, 1), + new QueryRequest(null, query.endTime(), 1, 1) + }) assertThatThrownBy(() -> client.queryPage(invalid)).isInstanceOf(LingxingWmsException.class); + assertThat(calls).hasValue(0); + } + + private LingxingWmsException assertFailure(LingxingWmsClient client, String code, boolean retryable) { + var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query())); + assertThat(error.code()).isEqualTo("LINGXING_WMS_" + code); + assertThat(error.retryable()).isEqualTo(retryable); + assertThat(error.getCause()).isNull(); + StringWriter trace = new StringWriter(); + error.printStackTrace(new PrintWriter(trace)); + assertThat(trace.toString()).doesNotContain(APP_KEY, APP_SECRET, PRIVATE_DATA, "sign=", "http://"); + return error; + } + + private LingxingWmsClient client(boolean enabled, String key, String secret, Duration interval) { + var properties = new LingxingWmsProperties(enabled, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + key, secret, Duration.ofSeconds(2), Duration.ofSeconds(2), interval); + return new LingxingWmsClient(properties, mapper, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static QueryRequest query() { + LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0); + return new QueryRequest(end.minusMonths(1), end, 1, 1); + } + + private static String hmac(String input) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java new file mode 100644 index 000000000..c34b9b55c --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java @@ -0,0 +1,91 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LingxingWmsSignerTest { + @Test + void matchesOfficialOmpParameterRenderingIncludingNamesPathAndBothSecrets() throws Exception { + var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + var signer = new LingxingWmsSigner(mapper); + var data = new LinkedHashMap(); + data.put("status", 0); + data.put("current", 1); + data.put("size", 10); + var signed = signer.sign("YOUR_APP_KEY", "YOUR_APP_SECRET", "/openapi/v2/customer/list", data, + Instant.ofEpochSecond(1790056664)); + var body = mapper.readTree(signed.body()); + String officialInput = "YOUR_APP_SECRET/openapi/v2/customer/listappKeyYOUR_APP_KEYdata{status=0, current=1, size=10}timestamp1790056664YOUR_APP_SECRET"; + assertThat(body.path("sign").textValue()).isEqualTo(hmac("YOUR_APP_SECRET", officialInput)); + var fieldNames = new ArrayList(); + body.path("data").fieldNames().forEachRemaining(fieldNames::add); + assertThat(fieldNames).containsExactly("status", "current", "size"); + assertThat(signed.body()).doesNotContain("\n", "YOUR_APP_SECRET", "authcode", "reqTime"); + assertThat(signed.toString()).doesNotContain("YOUR_APP_KEY", "YOUR_APP_SECRET", body.path("sign").textValue()); + } + + @Test + void nestedDataUsesTransmittedOrderAndMapListValueSyntax() throws Exception { + var mapper = new ObjectMapper(); + var signer = new LingxingWmsSigner(mapper); + var child = new LinkedHashMap(); + child.put("z", "中文"); + child.put("A", 2); + var data = new LinkedHashMap(); + data.put("items", List.of(child)); + var signed = signer.sign("key", "secret", "/openapi/v2/customer/list", data, Instant.ofEpochSecond(1770000000)); + String input = "secret/openapi/v2/customer/listappKeykeydata{items=[{z=中文, A=2}]}timestamp1770000000secret"; + assertThat(mapper.readTree(signed.body()).path("sign").textValue()).isEqualTo(hmac("secret", input)); + } + + @Test + void propertiesBindWmsOnlyAndRedactBothCredentials() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.integration.lingxing-wms.enabled", "true", + "baiyue.integration.lingxing-wms.app-key", "example-key", + "baiyue.integration.lingxing-wms.app-secret", "example-secret")); + var properties = new Binder(source).bind("baiyue.integration.lingxing-wms", Bindable.of(LingxingWmsProperties.class)).get(); + assertThat(properties.enabled()).isTrue(); + assertThat(properties.hasCredentials()).isTrue(); + assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com")); + assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15)); + assertThat(properties.minRequestInterval()).isEqualTo(Duration.ofSeconds(1)); + assertThat(properties.toString()).doesNotContain("example-key", "example-secret"); + } + + @Test + void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() { + var properties = new LingxingWmsProperties(false, null, null, null, null, null, null); + assertThat(properties.enabled()).isFalse(); + assertThat(properties.hasCredentials()).isFalse(); + assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static String hmac(String key, String input) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/scripts/prod-release-acceptance.mjs b/scripts/prod-release-acceptance.mjs index 77b12c81c..05ca45f06 100644 --- a/scripts/prod-release-acceptance.mjs +++ b/scripts/prod-release-acceptance.mjs @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { normalizeOrderInput, measurementPlan } from './prod-release-measurement-plan.mjs' -// Production is deliberately not routed through the multi-customer test runner. +// Single-order acceptance never invokes the multi-customer fixture runner. export const STAGES = Object.freeze([ 'CUSTOMER_ORDER', 'ORDER_REVIEW', 'YIWU_MEASUREMENT', 'CUSTOMER_FEE_CONFIRMATION', 'CONTAINER_ALLOCATION', 'DOMESTIC_PDA_LOADING', 'CUSTOMS_AND_BOOKING', @@ -20,9 +20,8 @@ export const DOMESTIC_WAREHOUSE_CODES = Object.freeze(['CN-YIWU-01', 'CN-DONGGUA export function plan() { return { scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true }, - status: 'FULL_FLOW_NOT_IMPLEMENTED', fullFlowVerified: false, - stages: STAGES.map(name => ({ name, status: ['CUSTOMER_ORDER', 'ORDER_REVIEW'].includes(name) - ? 'IMPLEMENTED_NOT_HTTP_VERIFIED' : 'NOT_IMPLEMENTED' })), + status: 'FULL_FLOW_IMPLEMENTED_NOT_HTTP_VERIFIED', fullFlowVerified: false, + stages: STAGES.map(name => ({ name, status: 'IMPLEMENTED_NOT_HTTP_VERIFIED' })), excluded: ['其他客户', '混柜及拆单', '退货及异常分支', '真实付款', '第三方下单或发送邮件', '浏览器和设备交互'], policy: { createMasterData: false, createAccounts: false, deleteData: false }, } @@ -43,20 +42,22 @@ function baseUrl(value, path) { } export function config(env) { - if (env.CHONGCHONG_ENV !== 'prod') throw new Error('必须显式配置 CHONGCHONG_ENV=prod') + if (!['test', 'gray'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test 或 gray') + for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) { + if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`) + } const get = key => requireValue(env, `CHONGCHONG_${key}`) - const cn = baseUrl(get('ADMIN_API_BASE_URL'), '/api') - const overseas = baseUrl(get('ADMIN_OVERSEAS_API_BASE_URL'), '/overseas-api') + const apiBase = baseUrl(get('ADMIN_API_BASE_URL'), '/api') const merchant = baseUrl(get('MERCHANT_API_BASE_URL'), '/api') - if (new URL(cn).origin !== new URL(overseas).origin || cn !== merchant) { - throw new Error('当前专属入口要求国内、海外及客户 API 使用同一 Admin 前端代理') + if (apiBase !== merchant) { + throw new Error('单票入口要求平台及客户 API 使用同一 HTTPS /api 统一入口') } const customer = { id: get('ACCEPTANCE_CUSTOMER_ID'), code: get('ACCEPTANCE_CUSTOMER_CODE'), name: get('ACCEPTANCE_CUSTOMER_NAME') } if (!/^[1-9]\d*$/.test(customer.id)) throw new Error('客户 ID 必须为非零十进制字符串') const credentials = Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role => [role, { loginId: get(`${role}_USERNAME`), password: get(`${role}_PASSWORD`) }])) if (get('OVERSEAS_LAX_PDA_WAREHOUSE_CODE') !== 'US-LAX-01') throw new Error('海外 PDA 必须绑定洛杉矶仓') - return { cn, overseas, customer, credentials } + return { environment: env.CHONGCHONG_ENV, apiBase, customer, credentials } } function id(value) { @@ -88,11 +89,11 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) { return { get requests() { return requests }, async request(region, method, path, token, body, idempotencyKey) { - if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内') - if (++requests > 100) throw new Error('超过单次前置检查请求上限') + if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在单票请求白名单内') + if (++requests > 100) throw new Error('超过单阶段请求上限') let response try { - response = await fetcher((region === 'cn' ? settings.cn : settings.overseas) + path, { + response = await fetcher(settings.apiBase + path, { method, redirect: 'error', signal: AbortSignal.timeout(20_000), headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }, @@ -129,7 +130,7 @@ export async function preflight(settings, http, receiveContext = () => {}) { const logins = {} for (const [role, credentials] of Object.entries(settings.credentials)) { logins[role] = await http.request('cn', 'POST', '/auth/login', undefined, credentials) - if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少国内 Token`) + if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少统一 accessToken`) } const admin = logins.ADMIN.accessToken assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer) @@ -152,8 +153,8 @@ export async function preflight(settings, http, receiveContext = () => {}) { !cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) { throw new Error('国内 PDA 不可访问义乌或东莞仓') } - const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken - if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token') + const overseasToken = logins.OVERSEAS_LAX_PDA.accessToken + if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少统一 accessToken') const binding = await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', overseasToken) if (binding?.warehouseCode !== 'US-LAX-01' || id(binding.warehouseId) !== warehouses['US-LAX-01']) throw new Error('洛杉矶 PDA 仓库绑定不符') const counts = {} @@ -169,14 +170,14 @@ export async function preflight(settings, http, receiveContext = () => {}) { return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests, checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'], resourceCounts: counts, - pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '测量至签收的写入执行器和检查点', '所有业务阶段真实 HTTP 验证'], + pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '全流程输入指定的资源资格', '本轮全部业务阶段真实 HTTP 验证'], } } export async function executeOrder(settings, input, journalPath, resume, fetcher = fetch) { const { validateOrderInput, withOrderJournal, runOrderStage } = await import('./prod-release-order-stage.mjs') // These facts come from the selected external environment, not from user-editable order JSON. - const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: settings.cn, + const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: { mode: settings.environment, apiBase: settings.apiBase }, operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } } validateOrderInput(bound) return withOrderJournal(journalPath, bound, async (state, save) => { @@ -229,9 +230,19 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher export async function main(argv = process.argv.slice(2), env = process.env) { const [mode = 'plan', ...rest] = argv + if (mode === 'full-flow') { + if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-full-flow' + || rest.length === 4 && rest[3] !== '--resume') throw Error('full-flow 需要输入文件、检查点、--confirm-single-full-flow,可选 --resume') + const inputPath = resolve(rest[0]), journalPath = resolve(rest[1]) + if (inputPath === journalPath) throw Error('输入和检查点不能使用同一文件') + const { executeFullFlow } = await import('./prod-release-full-flow.mjs') + const result = await executeFullFlow(config(env), JSON.parse(await readFile(inputPath, 'utf8')), journalPath, rest[3] === '--resume') + console.log(JSON.stringify(result, null, 2)) + return + } if (mode === 'delivery-stage') { - if (rest.length !== 3 || rest[2] !== '--confirm-production-delivery') { - throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-production-delivery') + if (rest.length !== 3 || rest[2] !== '--confirm-single-delivery') { + throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-single-delivery') } const inputPath = resolve(rest[0]), journalPath = resolve(rest[1]) if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件') @@ -252,8 +263,8 @@ export async function main(argv = process.argv.slice(2), env = process.env) { return } if (mode === 'order-stage') { - if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-production-order' - || rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-production-order,可选 --resume') + if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-order' + || rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-single-order,可选 --resume') const inputPath = resolve(rest[0]), journalPath = resolve(rest[1]) if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件') const input = JSON.parse(await readFile(inputPath, 'utf8')) @@ -261,7 +272,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) { console.log(JSON.stringify(result)) return } - if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];全链路执行尚未开放') + if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];完整链使用 full-flow <输入> <检查点> --confirm-single-full-flow') if (mode === 'plan') { console.log(JSON.stringify(plan(), null, 2)); return } const settings = config(env) const reportPath = resolve('reports', `prod-release-preflight-${Date.now()}.json`) @@ -276,5 +287,5 @@ export async function main(argv = process.argv.slice(2), env = process.env) { } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - main().catch(error => { console.error(`生产验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 }) + main().catch(error => { console.error(`单票验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 }) } diff --git a/scripts/prod-release-acceptance.test.mjs b/scripts/prod-release-acceptance.test.mjs index 2eadb69bb..5556e30bc 100644 --- a/scripts/prod-release-acceptance.test.mjs +++ b/scripts/prod-release-acceptance.test.mjs @@ -3,9 +3,8 @@ import assert from 'node:assert/strict' import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main, DOMESTIC_WAREHOUSE_CODES } from './prod-release-acceptance.mjs' function environment() { - const env = { CHONGCHONG_ENV: 'prod', + const env = { CHONGCHONG_ENV: 'gray', CHONGCHONG_ADMIN_API_BASE_URL: 'https://acceptance.invalid/api', - CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL: 'https://acceptance.invalid/overseas-api', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://acceptance.invalid/api', CHONGCHONG_ACCEPTANCE_CUSTOMER_ID: '9007199254740993', CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE: 'fixture', CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME: 'fixture', @@ -25,8 +24,8 @@ test('single order scope does not claim implemented or full coverage', () => { assert.deepEqual(result.scope.origins, [...DOMESTIC_WAREHOUSE_CODES]) assert.equal(result.scope.receiving, 'US-LAX-01') assert.equal(result.fullFlowVerified, false) - assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 2) - assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 12) + assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 14) + assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 0) }) test('configuration has no development/test fallback', () => { assert.throws(() => config({ ADMIN_TEST_GATEWAY: 'https://acceptance.invalid' })) @@ -85,7 +84,7 @@ function fakeHttp(bindingId = '3') { return { requests: 0, async request(region, method, path) { this.requests++ assert.equal(allowedRequest(method, path, region), true) - if (method === 'POST') return { accessToken: 'fixture-token', overseasAccessToken: 'fixture-overseas-token' } + if (method === 'POST') return { accessToken: 'fixture-token' } if (path.includes('/iam/')) return profile if (path.includes('/warehouse-profile')) return { warehouseId: bindingId, warehouseCode: 'US-LAX-01' } if (path.endsWith('/pda-loading/warehouses')) return [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }] @@ -110,6 +109,39 @@ test('PDA warehouse mismatch blocks preflight', async () => { }) test('execute, resume and unrecognized arguments fail closed', async () => { for (const args of [['execute'], ['resume'], ['preflight', '--execute'], ['plan', '--count', '2']]) { - await assert.rejects(main(args, {}), /尚未开放/) + await assert.rejects(main(args, {}), /完整链使用/) } }) + + +test('explicit gray/test mode shares one API base and rejects retired environment keys', () => { + for (const mode of ['gray', 'test']) { + const settings = config({ ...environment(), CHONGCHONG_ENV: mode }) + assert.equal(settings.environment, mode) + assert.equal(settings.apiBase, 'https://acceptance.invalid/api') + assert.equal(settings.overseas, undefined) + } + for (const mode of ['prod', 'dev', '', undefined]) { + assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/) + } + for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) { + assert.throws(() => config({ ...environment(), [key]: 'https://acceptance.invalid/api' }), /旧海外网关/) + } +}) + +test('overseas PDA calls the unified gateway using its ordinary access token', async () => { + const calls = [] + const http = client(config(environment()), async (url, options) => { + calls.push({ url, token: options.headers.Authorization }) + return { ok: true, status: 200, json: async () => ({ code: '200', data: {} }) } + }) + await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', 'ordinary-access') + assert.deepEqual(calls, [{ url: 'https://acceptance.invalid/api/pda/v1/last-leg/receiving-containers/warehouse-profile', token: 'Bearer ordinary-access' }]) +}) + +test('unapproved customer blocks preflight before any business stage', async () => { + const http = fakeHttp(), original = http.request.bind(http) + http.request = async (...args) => args[2].includes('/iam/') + ? { ...profile, status: 'PROFILE_REQUIRED' } : original(...args) + await assert.rejects(preflight(config(environment()), http), /审核状态/) +}) diff --git a/scripts/prod-release-delivery-stage.mjs b/scripts/prod-release-delivery-stage.mjs index 2789cf621..86f77e548 100644 --- a/scripts/prod-release-delivery-stage.mjs +++ b/scripts/prod-release-delivery-stage.mjs @@ -5,7 +5,8 @@ import { fingerprint } from './prod-release-order-stage.mjs' const check = (ok, message) => { if (!ok) throw new Error(message) } const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value) const segment = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value) -const number = value => Number.isSafeInteger(Number(value)) ? Number(value) : NaN +const number = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value)) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN export function validateDeliveryInput(input) { check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次') @@ -79,6 +80,7 @@ export async function runDeliveryStage({ input, customerId, state, save, request let delivery = await readDelivery() if (delivery.order.status === 'WAIT_DELIVERY') { check(!state.startIntent, '配送中操作结果不确定,禁止重发;请核对服务端状态') + check(Number.isSafeInteger(number(delivery.order.version)), '派送状态版本缺失或无效') await save({ startIntent: true }) await request('overseas', 'PUT', scoped(`${deliveryPath}/status`), { status: 'DELIVERING', expectedVersion: number(delivery.order.version), remark: `RELEASE-ACCEPTANCE:${input.batch}`, @@ -142,12 +144,12 @@ export async function runDeliveryStage({ input, customerId, state, save, request export async function executeDelivery(settings, input, journalPath, fetcher = fetch) { validateDeliveryInput(input) - const binding = fingerprint({ input, cn: settings.cn, overseas: settings.overseas, + const binding = fingerprint({ input, environment: settings.environment, apiBase: settings.apiBase, customer: settings.customer, admin: settings.credentials.ADMIN.loginId }) return withDeliveryJournal(journalPath, binding, async (state, save) => { const { client } = await import('./prod-release-acceptance.mjs') const login = await client(settings, fetcher).request('cn', 'POST', '/auth/login', null, settings.credentials.ADMIN) - check(login?.accessToken && login?.overseasAccessToken, '缺少国内或海外令牌') + check(login?.accessToken, '缺少统一 accessToken') const root = '/platform/v1/last-leg' const delivery = `${root}/delivery-orders/${input.deliveryNo}` const scoped = path => `${path}?customerId=${settings.customer.id}` @@ -161,13 +163,13 @@ export async function executeDelivery(settings, input, journalPath, fetcher = fe : region === 'overseas' && (method === 'GET' && reads.has(path) || method === 'PUT' && writes.has(path))) return runDeliveryStage({ input, customerId: settings.customer.id, state, save, request: (region, method, path, body) => http.request(region, method, path, - region === 'cn' ? login.accessToken : login.overseasAccessToken, body), + login.accessToken, body), upload: async (path, name, text) => { check(path === scoped(`${delivery}/documents/RECEIPT/files`), '上传路径不符') const form = new FormData(); form.append('file', new Blob([text], { type: 'text/plain' }), name) let response - try { response = await fetcher(settings.overseas + path, { method: 'POST', redirect: 'error', - signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.overseasAccessToken}` }, body: form }) } + try { response = await fetcher(settings.apiBase + path, { method: 'POST', redirect: 'error', + signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.accessToken}` }, body: form }) } catch { throw new Error('签收上传连接失败或超时;未自动重试') } const data = await response.json() const safe = value => /^[\w-]{1,100}$/.test(String(value)) ? value : 'unavailable' diff --git a/scripts/prod-release-delivery-stage.test.mjs b/scripts/prod-release-delivery-stage.test.mjs index cbd933ea8..0880f44f8 100644 --- a/scripts/prod-release-delivery-stage.test.mjs +++ b/scripts/prod-release-delivery-stage.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs' +import { executeDelivery, runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs' const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201', warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 } @@ -112,3 +112,49 @@ test('journal rejects changed binding and concurrent processes', async () => { await assert.rejects(withDeliveryJournal(path, 'changed', async () => {}), /不符/) } finally { await rm(dir, { recursive: true, force: true }) } }) + + +test('missing or malformed delivery version blocks status write and upload', async () => { + for (const version of [undefined, null, '', ' ', false, -1, '9007199254740993']) { + const h = harness(), original = h.options.request + h.options.request = async (...args) => { + const result = await original(...args) + if (result?.order?.deliveryNo) result.order.version = version + return result + } + await assert.rejects(runDeliveryStage(h.options), /版本缺失或无效/) + assert.equal(h.writes.length, 0) + assert.equal(h.uploads(), 0) + } +}) + +test('delivery executor uses one gateway/accessToken for status, multipart receipt and domestic reconciliation', async t => { + const dir = await mkdtemp(join(tmpdir(), 'single-delivery-test-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const h = harness(), calls = [] + const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: { id: '501' }, + credentials: { ADMIN: { loginId: 'admin-fixture', password: 'secret-fixture' } } } + const fetcher = async (url, options) => { + assert.ok(url.startsWith(settings.apiBase + '/')) + const path = url.slice(settings.apiBase.length) + calls.push(path) + let data + if (path === '/auth/login') data = { accessToken: 'single-access' } + else { + assert.equal(options.headers.Authorization, 'Bearer single-access') + if (options.body instanceof FormData) { + assert.equal(path, '/platform/v1/last-leg/delivery-orders/DL-101/documents/RECEIPT/files?customerId=501') + assert.match(await options.body.get('file').text(), /NOT A REAL PROOF/) + data = { fileId: '701' } + } else { + data = await h.options.request(path.startsWith('/platform/v1/last-leg/') ? 'overseas' : 'cn', + options.method, path, options.body ? JSON.parse(options.body) : undefined) + } + } + return { ok: true, status: 200, json: async () => ({ code: '200', data }) } + } + const result = await executeDelivery(settings, input, join(dir, 'journal.json'), fetcher) + assert.equal(result.status, 'DELIVERY_STAGE_PASSED') + assert.equal(result.fullFlowVerified, false) + assert.equal(calls.filter(path => path === '/auth/login').length, 1) +}) diff --git a/scripts/prod-release-domestic-stage.mjs b/scripts/prod-release-domestic-stage.mjs new file mode 100644 index 000000000..40f36fc91 --- /dev/null +++ b/scripts/prod-release-domestic-stage.mjs @@ -0,0 +1,231 @@ +import { fingerprint } from './prod-release-order-stage.mjs' +import { oceanBookingDraft } from './admin-test-env-modules/ocean-booking-fixture.mjs' +import { iso6346ContainerNumber } from './admin-test-env-modules/iso6346-container-number.mjs' +import { channelGroup } from './admin-test-env-modules/smart-loading-channel-group.mjs' + +const assert = (value, message) => { if (!value) throw Error(message) } +const id = value => value == null ? '' : String(value) +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64') +const NODES = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'] +const localTime = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 19) +function fileForm(name, content = PNG, type = 'image/png', fields = {}) { + const form = new FormData() + for (const [key, value] of Object.entries(fields)) form.append(key, String(value)) + form.append('file', new Blob([content], { type }), name) + return form +} +export function domesticSuccess(response) { + if (response?.http !== 200 || String(response?.body?.code) !== '200') { + const error = Error(`国内履约请求失败 HTTP=${response?.http} code=${response?.body?.code}`) + error.evidence = { http: response?.http, code: response?.body?.code, message: response?.body?.message, + requestId: response?.requestId, traceId: response?.traceId } + throw error + } + return response.body.data +} + +// Persist intent before invoking transport. Never retry a partially observed write. +export function domesticCommands(checkpoint, binding) { + const { state, save } = checkpoint + assert(!state.binding || state.binding === binding, '国内检查点绑定发生变化') + return async (name, command, execute) => { + const previous = state.commands?.[name] + if (previous?.status === 'DONE') return previous.result + assert(!previous, `国内写入 ${name} 结果未知,必须人工核对原请求,禁止自动重放`) + const intent = { status: 'INTENT', command, fingerprint: fingerprint(command) } + const persist = async value => { + const patch = { binding, commands: { ...state.commands, [name]: value } } + await save(patch); Object.assign(state, patch) + } + await persist(intent) + const result = await execute() + await persist({ ...intent, status: 'DONE', result }) + return result + } +} + +export async function runDomesticStage({ input, order, context, checkpoint }) { + const config = input.domestic + const cargo = input.payload.cargoes[0] + assert(order?.status === 'ORDER_APPROVED' && order.orderId && order.inboundCode, '必须使用本票已审核订单') + assert(cargo.packageCount >= 1 && cargo.packageCount <= 2 && input.payload.cargoes.length === 1, '国内阶段仅支持本票一个 SKU、1~2 箱') + assert(input.payload.taxMode === 'BUY_DECLARATION', '当前专票须为买单报关,不自动改写报关方式') + assert(config?.palletId && config.assigneeId && config.bookingAgent?.id && config.carrier?.id + && config.carrier.carrierCode && config.estimatedSailingAt && config.estimatedArrivalAt + && config.terminalFleet?.id && config.terminalFleet.name, '缺少已有国内资源、目的港车队或明确船期') + assert(context.token && context.pdaToken && context.merchantToken, '缺少已登录平台、国内 PDA 或客户会话') + const binding = fingerprint({ input, orderId: order.orderId }) + const command = domesticCommands(checkpoint, binding) + const tenantId = input.customer.id, warehouseId = input.origin.id + const q = `tenantId=${encodeURIComponent(tenantId)}` + const key = name => `release-${fingerprint({ binding, name }).slice(0, 48)}` + const get = async (path, token = context.token) => domesticSuccess(await context.request('GET', path, undefined, token)) + const write = (name, method, path, body, token = context.token) => command(name, { method, path, body, idempotencyKey: key(name) }, + async () => domesticSuccess(await context.request(method, path, body, token, { 'Idempotency-Key': key(name) }))) + const upload = (name, path, form, token = context.token) => command(name, { method: 'POST', path, idempotencyKey: key(name), testEvidence: name }, + async () => domesticSuccess(await context.requestMultipart('POST', path, form, token, { 'Idempotency-Key': key(name) }))) + const poll = async (read, condition, description) => { + for (let i = 0; i < (context.pollAttempts || 45); i++) { + const data = await read() + if (condition(data)) return data + await (context.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000) + } + throw Error(`国内异步事实未就绪:${description}`) + } + const unified = await poll(() => get(`/api/platform/v1/orders/${order.orderId}?${q}`), data => data.summary?.inboundId, '订单入库投影') + const inboundId = id(unified.summary.inboundId) + const inboundPath = `/api/platform/inbounds/${inboundId}?${q}` + const base = `/api/platform/v1/first-leg/inbounds/${inboundId}` + const measurementPath = `${base}/measurement?${q}` + const inbound = await get(inboundPath) + assert(id(inbound.summary?.tenantId) === tenantId && inbound.summary.inboundCode === order.inboundCode + && id(inbound.summary?.orderId) === order.orderId, '入库单客户、订单或入仓码不属于本票') + await write('accept', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/accept?${q}`) + await write('start', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/start?${q}`) + await command('carton-labels', { method: 'POST', path: `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}` }, async () => { + const result = await context.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`) + assert(result.http === 200 && String(result.contentType).includes('pdf') && Buffer.from(result.bytes || []).subarray(0, 4).toString() === '%PDF', '本票箱唛未成功生成') + return { generated: true } + }) + const scanForm = new FormData() + scanForm.append('data', new Blob([JSON.stringify({ boxCode: `${order.inboundCode}#${cargo.packageCount}-1`, + length: cargo.lengthCm, width: cargo.widthCm, height: cargo.heightCm, + machineNo: 'TEST-RELEASE-SIMULATED', measuredAt: localTime() })], { type: 'application/json' })) + scanForm.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png') + const scan = await upload('carton-scan', `${base}/measurement/carton-scans?${q}`, scanForm) + assert(scan?.itemId && scan.measurementId, '测量扫描未生成本票明细') + await write('carton-count', 'PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`, + { measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: cargo.packageCount }) + const measured = await get(measurementPath) + assert(measured.items?.length === 1 && id(measured.items[0].id) === id(scan.itemId), '本票测量明细数量不符') + const item = measured.items[0] + await write('measurement-draft', 'PUT', `${base}/measurement/draft?${q}`, { + measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: cargo.goodsName || cargo.skuNo, + items: [{ inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, + packageCount: cargo.packageCount, lengthCm: cargo.lengthCm, widthCm: cargo.widthCm, heightCm: cargo.heightCm, + intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: cargo.lengthCm, outerWidthCm: cargo.widthCm, outerHeightCm: cargo.heightCm, + coefficientMode: 'ADD', remark: `${input.batch} TEST模拟测量,非实测证据` }] }) + const weightFile = await upload('weight-file', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, fileForm('TEST-weight.png')) + const weight = await write('weight-create', 'POST', `${base}/weight-results?${q}`, { fileId: weightFile.fileId, palletId: config.palletId, + ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null }) + assert(weight.palletTareWeightKg != null && Number(weight.palletTareWeightKg) >= 0, '缺少已有托盘皮重快照') + await write('weight-confirm', 'POST', `${base}/weight-results/${weight.id}/confirm?${q}`, { + version: Number(weight.version), adjustedWeightValue: Number(cargo.grossWeightKg) + Number(weight.palletTareWeightKg), + adjustedWeightUnit: 'KG', adjustmentReason: `${input.batch} TEST模拟称重,申报货重加托盘皮重` }) + const ready = await get(measurementPath) + await write('measurement-submit', 'POST', `${base}/measurement/submit?${q}`, { measurementId: ready.measurementId, version: Number(ready.version) }) + const pending = await get(inboundPath) + await write('measurement-approve', 'POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`, + { measurementId: ready.measurementId, expectedVersion: Number(pending.summary.version), reason: `${input.batch} TEST专票流程` }) + const approved = await poll(() => get(measurementPath), value => value.processStatus === 'WAIT_SPLIT' + || checkpoint.state.commands?.['plan-assign']?.status === 'DONE', '测量审核待分柜') + assert(approved.measurementStatus === 'APPROVED' && approved.measureReviewStatus === 'APPROVED' + && Number(approved.actualPackageCount) === cargo.packageCount + && Math.abs(Number(approved.actualNetWeightKg) - cargo.grossWeightKg) <= 0.001, '测量审核、箱数或净重不符') + const latestFee = await context.request('GET', `/api/platform/v1/inbounds/${inboundId}/fees/latest?${q}`, undefined, context.token) + if (latestFee.http === 200 && String(latestFee.body?.code) === 'A0404') { + await write('fee-calculate', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/calculate?${q}`) + } else domesticSuccess(latestFee) + await write('fee-confirm', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/confirm?${q}`) + const sent = await write('fee-send', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/send?${q}`) + assert(sent.feeStatus === 'SENT', '头程费用未发送客户') + const billing = `/api/merchant/v1/first-leg/orders/${order.orderId}/inbound-billing` + const customerFee = await write('customer-fee-confirm', 'POST', `${billing}/confirm`, { expectedFeeVersion: Number(sent.feeVersion) }, context.merchantToken) + assert(customerFee.customerConfirmationStatus === 'CONFIRMED', '客户费用未确认') + const containers = '/api/platform/v1/containers', smart = '/api/platform/v1/first-leg/smart-loading', pda = '/api/platform/v1/first-leg/pda-loading' + const simulation = await write('container-create', 'POST', containers, { channelGroupCode: channelGroup(input.payload.channelCode), warehouseId, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 }) + const containerId = id(simulation.id) + const booking = await write('booking-create', 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, + data: oceanBookingDraft({ marker: input.batch }, input.batch, { customerId: tenantId, customerName: input.customer.name, + channelCode: input.payload.channelCode, warehouseId }, { shippingCompanyId: config.carrier.id, shippingCompanyName: config.carrier.name, + carrierCode: config.carrier.carrierCode, bookingAgentId: config.bookingAgent.id, bookingAgentName: config.bookingAgent.name, + estimatedSailingAt: config.estimatedSailingAt, estimatedArrivalAt: config.estimatedArrivalAt }) }) + const oceanBookingId = id(booking.oceanBooking.id) + const document = await upload('booking-document', `/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`, + fileForm('TEST-booking-confirmation.txt', `${input.batch} TEST模拟订舱确认文件,非真实单证`, 'text/plain', + { expectedVersion: booking.oceanBooking.version, documentType: 'BOOKING_CONFIRMATION' })) + const released = await write('booking-release', 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, + { expectedVersion: Number(document.bookingVersion), reason: `${input.batch} TEST` }) + assert(released.oceanBooking?.status === 'RELEASED', '订舱未放舱') + const requirement = released.containerRequirements?.find(value => value.containerType === '40HQ') + assert(requirement?.id, '订舱缺少40HQ需求') + const containerNo = iso6346ContainerNumber(input.batch) + await write('booking-link', 'POST', `${containers}/${containerId}/ocean-booking-link`, { oceanBookingId, + requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version) }) + const candidates = await get(`${smart}/candidates?bookingId=${containerId}&warehouseId=${warehouseId}`) + const candidate = candidates.find(value => id(value.orderId) === order.orderId && id(value.accountId) === tenantId) + // After assignment a resumed run uses the captured candidate, never another order. + const assignedBefore = checkpoint.state.commands?.['plan-assign'] + assert(candidate || assignedBefore?.status === 'DONE', '本票不在可分柜候选中') + const plan = await get(`${smart}/plans/by-booking/${containerId}`), planId = id(plan.id) + const assigned = await write('plan-assign', 'POST', `${smart}/plans/${planId}/assignments`, { + inboundId, inboundDetailId: id(candidate?.inboundDetailId), packageCount: cargo.packageCount, + expectedVersion: Number(plan.version), operationId: key('plan-assign') }) + assert(assigned.items?.length === 1 && Number(assigned.items[0].packageCount) === cargo.packageCount, '装柜计划混入其他货物或数量不符') + const confirmable = await get(`/api/platform/v1/first-leg/loading-plans/${planId}?warehouseId=${warehouseId}`) + const confirmed = await write('plan-confirm', 'POST', `/api/platform/v1/first-leg/loading-plans/${planId}/confirm`, + { expectedVersion: Number(confirmable.summary.version), assigneeId: config.assigneeId }) + const taskId = id(confirmed.readiness?.pdaTaskId) + assert(taskId && confirmed.confirmationStatus === 'CONFIRMED', '国内 PDA 任务未生成') + const claimed = await write('pda-claim', 'POST', `${pda}/tasks/${taskId}/claim`, + { expectedAssignmentVersion: 0, operationId: key('pda-claim') }, context.pdaToken) + assert(claimed.items?.length === 1, '国内 PDA 任务货物不唯一') + const snapshot = await get(`${pda}/tasks/${taskId}/inventory-snapshot?warehouseId=${warehouseId}`, context.pdaToken) + const picked = await write('pda-pick', 'POST', `${pda}/tasks/${taskId}/batch`, { warehouseId, loadingPlanId: planId, + containerBookingId: containerId, expectedVersion: Number(snapshot.taskVersion), inventorySnapshotToken: snapshot.snapshotToken, + items: [{ itemId: id(claimed.items[0].id), action: 'PICK', pickedPackageCount: cargo.packageCount, version: Number(claimed.items[0].version) }] }, context.pdaToken) + for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { + const params = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, remark: `${input.batch} TEST模拟凭证` }) + if (evidenceType === 'SEAL') params.set('sealNo', `TEST-${input.batch}`.slice(0, 64)) + await upload(`loading-${evidenceType}`, `${pda}/tasks/${taskId}/evidence?${params}`, fileForm(`TEST-${evidenceType}.png`), context.pdaToken) + } + await write('pda-complete', 'POST', `${pda}/tasks/${taskId}/complete`, { expectedTaskVersion: Number(picked.version), + operationId: key('pda-complete'), remark: `${input.batch} TEST装柜` }, context.pdaToken) + await poll(() => get(`${pda}/tasks/${taskId}?warehouseId=${warehouseId}`, context.pdaToken), + value => value.taskStatus === 'COMPLETED' && value.workflowStatus === 'COMPLETED', '国内装柜完成') + const customsPath = `/api/platform/v1/customs/loading-plans/${planId}` + let customs = await get(customsPath) + assert(customs.inbounds?.length === 1 && id(customs.inbounds[0].inboundId) === inboundId + && customs.inbounds.every(value => value.taxMode === 'BUY_DECLARATION'), '报关聚合不是本票买单货物') + if (customs.status === 'READY_TO_START') customs = await write('customs-start', 'POST', `${customsPath}/start`, + { expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` }) + if (customs.status === 'DECLARING') customs = await write('customs-complete', 'POST', `${customsPath}/complete-all-buy`, + { expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` }) + assert(customs.status === 'COMPLETED', '国内报关未完成') + const outboundPath = `/api/platform/v1/first-leg/outbound-dispatches/${containerId}` + const outbound = await poll(() => get(outboundPath), value => value.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING', '报关自动国内离仓') + assert(outbound.aggregate.inbounds?.length === 1 && id(outbound.aggregate.inbounds[0].inboundId) === inboundId, '离仓货物归属不符') + const queries = await poll(() => get(`/api/platform/v1/carrier-schedule-queries?containerBookingId=${containerId}&page=1&size=20`), + value => value.records?.some(row => row.query?.triggerSource === 'OUTBOUND_CONFIRM'), '离仓船节点任务') + const owned = queries.records.filter(row => id(row.query?.containerBookingId) === containerId && row.query.triggerSource === 'OUTBOUND_CONFIRM') + assert(owned.length === 1, '本柜船节点任务不唯一') + const carrierQueryId = id(owned[0].query.id), carrierPath = `/api/platform/v1/carrier-schedule-queries/${carrierQueryId}` + let carrierDetail = await get(carrierPath) + let start = NODES.indexOf(carrierDetail.query?.query?.latestActualNode) + assert(start >= 0, '缺少自动离仓起点') + for (let index = start + 1; index < NODES.length; index++) { + const latest = carrierDetail.query.query + // Current observed wall clock; actual timestamps advance from the authoritative last node. + const actualAt = new Date(Date.parse(`${latest.latestActualAt}Z`) + 1000).toISOString().slice(0, 19) + carrierDetail = await write(`carrier-${NODES[index]}`, 'POST', `${carrierPath}/manual-nodes`, { + expectedVersion: Number(latest.version), standardNode: NODES[index], observedAt: localTime(), actualAt, estimatedAt: null, + locationCode: 'USLAX', locationName: 'Los Angeles', reason: `${input.batch} TEST模拟船节点 ${NODES[index]}` }) + } + assert(carrierDetail.query?.query?.latestActualNode === 'PICKUP_READY', '船节点未到可提柜') + const terminalPath = `/api/platform/v1/terminal-drayages/${containerId}` + const initialTerminal = await get(terminalPath) + assert(id(initialTerminal.containerBookingId) === containerId && initialTerminal.containerNo === containerNo, '目的港拖车货柜不符') + assert(!initialTerminal.fleetSupplierId || checkpoint.state.commands?.['terminal-fleet']?.status === 'DONE', '禁止更换既有车队触发邮件') + const selected = await write('terminal-fleet', 'POST', `${terminalPath}/fleet`, { expectedVersion: Number(initialTerminal.version), + fleetSupplierId: config.terminalFleet.id, fleetSupplierName: config.terminalFleet.name, reason: `${input.batch} TEST` }) + const appointmentAt = new Date(Date.parse(`${carrierDetail.query.query.latestActualAt}Z`) + 3600000).toISOString().slice(0, 19) + const scheduled = await write('terminal-appointment', 'POST', `${terminalPath}/pickup-appointment`, { + expectedVersion: Number(selected.version), pickupAppointmentAt: appointmentAt, reason: `${input.batch} TEST` }) + assert(scheduled.pickupAppointmentAt === appointmentAt, '目的港提柜预约未持久化') + return { status: 'DOMESTIC_PICKUP_SCHEDULED', orderId: order.orderId, orderNo: order.orderNo, tenantId, warehouseId, + inboundId, inboundCode: order.inboundCode, containerId, containerBookingId: containerId, containerNo, + oceanBookingId, planId, loadingPlanId: planId, taskId, carrierQueryId, packageCount: cargo.packageCount, + terminalDrayageVersion: Number(scheduled.version), pickupAppointmentAt: appointmentAt } +} diff --git a/scripts/prod-release-domestic-stage.test.mjs b/scripts/prod-release-domestic-stage.test.mjs new file mode 100644 index 000000000..39b9a8ed0 --- /dev/null +++ b/scripts/prod-release-domestic-stage.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { domesticCommands, domesticSuccess, runDomesticStage } from './prod-release-domestic-stage.mjs' + +const journal = () => { + const state = {}, saves = [] + return { state, saves, async save(patch) { saves.push(structuredClone(patch)); Object.assign(state, patch) } } +} +test('uncertain writes retain original intent and cannot replay', async () => { + const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound') + let count = 0 + const execute = async () => { count++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw Error('network') } + await assert.rejects(run('create', { path: '/create' }, execute), /network/) + await assert.rejects(run('create', { path: '/create' }, execute), /结果未知/) + assert.equal(count, 1) +}) +test('completed writes reuse exact saved result and reject another binding', async () => { + const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound') + const first = await run('create', { path: '/create' }, async () => ({ id: '17' })) + assert.deepEqual(await run('create', { path: '/create' }, () => { throw Error('must not run') }), first) + assert.throws(() => domesticCommands(checkpoint, 'other'), /绑定/) +}) +test('business rejection retains diagnostic evidence and cannot count as success', () => { + assert.throws(() => domesticSuccess({ http: 200, body: { code: 'A0409', message: 'version' }, requestId: 'r', traceId: 't' }), + error => error.evidence.code === 'A0409' && error.evidence.requestId === 'r' && error.evidence.traceId === 't') + assert.throws(() => domesticSuccess({ http: 503, body: { code: '200' } })) +}) + +function fixture() { + const input = { batch: 'release-test-001', customer: { id: '1', name: 'TEST' }, origin: { id: '2' }, + payload: { taxMode: 'BUY_DECLARATION', channelCode: 'MATSON_CLX', cargoes: [{ skuNo: 'TEST-SKU', packageCount: 2, + lengthCm: 30, widthCm: 20, heightCm: 10, grossWeightKg: 8 }] }, + domestic: { palletId: '3', assigneeId: '4', bookingAgent: { id: '5', name: 'AGENT' }, + carrier: { id: '6', name: 'CARRIER', carrierCode: 'MATSON' }, terminalFleet: { id: '7', name: 'FLEET' }, + estimatedSailingAt: '2026-10-12T08:00:00', estimatedArrivalAt: '2026-10-20T08:00:00' } } + const order = { status: 'ORDER_APPROVED', orderId: '10', orderNo: 'TEST-ORDER', inboundCode: 'TEST-INBOUND' } + const checkpoint = journal(), calls = [] + const context = { token: 'platform-test', merchantToken: 'merchant-test', pdaToken: 'pda-test', pollAttempts: 1, sleep: async () => {} } + let containerNo, carrierNode = 'DEPARTED_WAREHOUSE', carrierVersion = 0, latestActualAt = '2026-10-12T10:00:00' + let measured = false + const carrier = () => ({ query: { query: { latestActualNode: carrierNode, version: carrierVersion, latestActualAt } } }) + context.request = async (method, path, body, token, headers) => { + calls.push({ method, path, body, token, headers }) + let data + if (path === '/api/platform/v1/orders/10?tenantId=1') data = { summary: { inboundId: '20' } } + else if (path === '/api/platform/inbounds/20?tenantId=1') data = { summary: { tenantId: '1', orderId: '10', inboundCode: 'TEST-INBOUND', version: 1 } } + else if (path.includes('/measurement?')) data = { measurementId: '30', version: 2, items: [{ id: '31', inboundDetailId: '32', itemNo: 1 }], + processStatus: measured ? 'WAIT_SPLIT' : 'MEASURING', measurementStatus: 'APPROVED', measureReviewStatus: 'APPROVED', actualPackageCount: 2, actualNetWeightKg: 8 } + else if (path.includes('/measurements/approve')) { measured = true; data = {} } + else if (path.includes('/weight-results?')) data = { id: '40', version: 0, palletTareWeightKg: 2 } + else if (path.includes('/fees/send')) data = { feeStatus: 'SENT', feeVersion: 1 } + else if (path.includes('/inbound-billing/confirm')) data = { customerConfirmationStatus: 'CONFIRMED' } + else if (path === '/api/platform/v1/containers') data = { id: '50', version: 0 } + else if (path === '/api/platform/v1/ocean-bookings') data = { oceanBooking: { id: '60', version: 0 } } + else if (path.includes('/ocean-bookings/60/release')) data = { oceanBooking: { status: 'RELEASED' }, containerRequirements: [{ id: '61', containerType: '40HQ' }] } + else if (path.includes('/ocean-booking-link')) { containerNo = body.containerNo; data = {} } + else if (path.includes('/smart-loading/candidates')) data = [{ orderId: '10', accountId: '1', inboundDetailId: '32' }] + else if (path.includes('/plans/by-booking/')) data = { id: '70', version: 0 } + else if (path.includes('/plans/70/assignments')) data = { items: [{ id: '71', packageCount: 2 }] } + else if (path.includes('/loading-plans/70/confirm')) data = { confirmationStatus: 'CONFIRMED', readiness: { pdaTaskId: '80' } } + else if (path.includes('/first-leg/loading-plans/70?')) data = { summary: { version: 1 } } + else if (path.includes('/tasks/80/claim')) data = { items: [{ id: '81', version: 0 }] } + else if (path.includes('/tasks/80/inventory-snapshot')) data = { taskVersion: 1, snapshotToken: 'snapshot' } + else if (path.includes('/tasks/80/batch')) data = { version: 2 } + else if (path.includes('/tasks/80?')) data = { taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' } + else if (path === '/api/platform/v1/customs/loading-plans/70') data = { inbounds: [{ inboundId: '20', taxMode: 'BUY_DECLARATION' }], status: 'READY_TO_START', version: 0 } + else if (path.endsWith('/customs/loading-plans/70/start')) data = { status: 'DECLARING', version: 1 } + else if (path.endsWith('/complete-all-buy')) data = { status: 'COMPLETED', version: 2 } + else if (path.includes('/outbound-dispatches/50')) data = { aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', inbounds: [{ inboundId: '20' }] } } + else if (path.includes('/carrier-schedule-queries?')) data = { records: [{ query: { id: '90', containerBookingId: '50', triggerSource: 'OUTBOUND_CONFIRM' } }] } + else if (path.endsWith('/carrier-schedule-queries/90')) data = carrier() + else if (path.endsWith('/manual-nodes')) { carrierNode = body.standardNode; carrierVersion++; latestActualAt = body.actualAt; data = carrier() } + else if (path.endsWith('/terminal-drayages/50')) data = { containerBookingId: '50', containerNo, version: 0 } + else if (path.endsWith('/terminal-drayages/50/fleet')) data = { version: 1 } + else if (path.endsWith('/pickup-appointment')) data = { version: 2, pickupAppointmentAt: body.pickupAppointmentAt } + else if (/\/(accept|start)\?/.test(path) || /confirmed-package-count|measurement\/draft|weight-results\/40\/confirm|measurement\/submit|fees\/latest|fees\/confirm|tasks\/80\/complete/.test(path)) data = {} + else throw Error(`unhandled ${method} ${path}`) + return { http: 200, body: { code: '200', data } } + } + context.requestMultipart = async (method, path, form, token, headers) => { + assert.ok(form instanceof FormData) + calls.push({ method, path, token, headers, multipart: true }) + let data + if (path.includes('/carton-scans')) data = { itemId: '31', measurementId: '30', measurementVersion: 0 } + else if (path.includes('/files/measurements/')) data = { fileId: '41' } + else if (path.includes('/documents/upload')) data = { bookingVersion: 1 } + else if (path.includes('/tasks/80/evidence')) data = { taskId: '80' } + else throw Error(`unhandled upload ${path}`) + return { http: 200, body: { code: '200', data } } + } + context.requestBinary = async () => ({ http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-test') }) + return { input, order, context, checkpoint, calls } +} + +test('single-ticket positive HTTP orchestration conserves two cartons and stops at pickup appointment', async () => { + const value = fixture(), result = await runDomesticStage(value) + assert.equal(result.status, 'DOMESTIC_PICKUP_SCHEDULED') + assert.equal(result.inboundId, '20') + assert.equal(result.packageCount, 2) + const batch = value.calls.find(call => call.path.endsWith('/tasks/80/batch')) + assert.equal(batch.body.items[0].pickedPackageCount, 2) + assert.equal(batch.token, value.context.pdaToken) + const weight = value.calls.find(call => call.path.includes('/weight-results/40/confirm')) + assert.equal(weight.body.adjustedWeightValue, 10) + assert.equal(value.calls.filter(call => call.path.endsWith('/manual-nodes')).length, 7) + assert.equal(value.calls.filter(call => call.path.includes('/evidence?')).length, 5) + assert.ok(value.calls.every(call => !/pickup-email|\/suppliers|\/users|\/customers/.test(call.path))) + assert.ok(Object.values(value.checkpoint.state.commands).every(command => command.status === 'DONE')) +}) +test('tax mode and customer identity fail closed before unrelated business writes', async () => { + const value = fixture() + value.input.payload.taxMode = 'SELF_DECLARATION' + await assert.rejects(runDomesticStage(value), /买单报关/) + assert.equal(value.calls.length, 0) +}) +test('a timeout at first business write preserves intent and stops all later actions', async () => { + const value = fixture(), request = value.context.request + value.context.request = async (...args) => args[0] === 'POST' ? Promise.reject(Error('timeout')) : request(...args) + await assert.rejects(runDomesticStage(value), /timeout/) + assert.equal(value.checkpoint.state.commands.accept.status, 'INTENT') + assert.equal(Object.keys(value.checkpoint.state.commands).length, 1) +}) diff --git a/scripts/prod-release-full-flow.mjs b/scripts/prod-release-full-flow.mjs new file mode 100644 index 000000000..e8e0cdaa5 --- /dev/null +++ b/scripts/prod-release-full-flow.mjs @@ -0,0 +1,93 @@ +import { resolve } from 'node:path' +import { fingerprint, validateOrderInput } from './prod-release-order-stage.mjs' +import { normalizeOrderInput } from './prod-release-measurement-plan.mjs' +import { executeOrder } from './prod-release-acceptance.mjs' +import { executeDelivery } from './prod-release-delivery-stage.mjs' +import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs' +import { runDomesticStage } from './prod-release-domestic-stage.mjs' +import { runOverseasStage, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' + +export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) { + const input = { ...normalizeOrderInput(rawInput), customer: settings.customer, + environment: { mode: settings.environment, apiBase: settings.apiBase }, + operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } } + validateOrderInput(input) + const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) }) + return withFlowJournal(journalPath, binding, async (state, save) => { + if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume') + const ctx = createFlowContext({ settings, state, save, fetcher }) + ctx.marker = input.batch + const checkpoint = name => { + const local = state.stages[name] || {} + return { state: local, save: async patch => { + const next = { ...local, ...patch } + await save({ stages: { ...state.stages, [name]: next } }); Object.assign(local, next) + } } + } + const bridgeFetch = async (url, options) => { + if (!url.startsWith(settings.apiBase + '/')) throw Error('全流程请求偏离统一Gateway') + const path = '/api' + url.slice(settings.apiBase.length) + const token = options.headers?.Authorization?.replace(/^Bearer /, '') + const body = options.body instanceof FormData ? options.body : options.body ? JSON.parse(options.body) : undefined + const result = await ctx.requestWithBody(options.method, path, body, token ?? null, + options.headers?.['Idempotency-Key'] ? { 'Idempotency-Key': options.headers['Idempotency-Key'] } : {}) + if (path === '/api/auth/login' && result.body?.code === '200') ctx.registerSession(result.body.data) + return { ok: result.http >= 200 && result.http < 300, status: result.http, json: async () => result.body } + } + try { + await save({ status: 'RUNNING' }) + ctx.token = (await ctx.login('ADMIN')).accessToken + ctx.merchantToken = (await ctx.login('MERCHANT')).accessToken + ctx.pdaToken = (await ctx.login('CN_PDA')).accessToken + ctx.overseasPdaToken = (await ctx.login('OVERSEAS_LAX_PDA')).accessToken + const binding = requireSuccess(await ctx.request('GET', '/api/pda/v1/last-leg/receiving-containers/warehouse-profile', undefined, ctx.overseasPdaToken)) + if (binding.warehouseCode !== 'US-LAX-01') throw Error('海外PDA不是洛杉矶接柜仓') + const orderCp = checkpoint('order') + let order = orderCp.state.result + if (!order) { + order = await executeOrder(settings, input, resolve(`${journalPath}.order.json`), resume, bridgeFetch) + await orderCp.save({ result: order }) + } + console.log('单票阶段:ORDER_APPROVED') + const domesticCp = checkpoint('domestic') + let domestic = domesticCp.state.result + if (!domestic) { + domestic = await runDomesticStage({ input, order, context: ctx, checkpoint: domesticCp }) + await domesticCp.save({ result: domestic }) + } + console.log(`单票阶段:${domestic.status}`) + const overseasCp = checkpoint('overseas') + let overseas = overseasCp.state.result + if (!overseas) { + overseas = await runOverseasStage({ input: { ...input, customerId: settings.customer.id, + packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId }, + domestic: { ...domestic, sourceInboundId: domestic.inboundId, orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode }, + context: ctx, checkpoint: overseasCp }) + await overseasCp.save({ result: overseas }) + } + console.log(`单票阶段:${overseas.status}`) + const delivery = await executeDelivery(settings, { batch: input.batch, ...overseas, + orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode, + packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId }, + resolve(`${journalPath}.delivery.json`), bridgeFetch) + if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`) + const tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') }) + const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true, + scope: 'ONE_ORDER_NORMAL_HTTP_PATH', orderNo: order.orderNo, inboundCode: order.inboundCode, + containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo, + domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus, + tripClosureStatus: tripClosure.status, + syntheticEvidence: true, excluded: ['真实货物及设备', '浏览器视觉', '故障注入', '多客户/拆单/异常分支', '真实付款/第三方下单/邮件'], + completedAt: new Date().toISOString() } + await save({ status: 'COMPLETED', result }) + return result + } catch (error) { + await save({ status: 'BLOCKED', failure: error.message }) + throw error + } finally { + const cleanup = await ctx.close() + if (state.result) state.result.sessionCleanup = cleanup.length ? 'FAILED' : 'COMPLETED' + await save({ cleanup }) + } + }) +} diff --git a/scripts/prod-release-http.mjs b/scripts/prod-release-http.mjs new file mode 100644 index 000000000..d0d0c7af2 --- /dev/null +++ b/scripts/prod-release-http.mjs @@ -0,0 +1,123 @@ +import { createHash } from 'node:crypto' +import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises' +import { dirname } from 'node:path' + +const hash = value => createHash('sha256').update(value).digest('hex') +const safe = value => typeof value === 'string' && /^[\w-]{1,100}$/.test(value) ? value : 'unavailable' +export const success = response => response?.http === 200 && response.body?.code === '200' +export function requireSuccess(response) { + if (!success(response)) throw new Error(`HTTP=${response?.http}; code=${safe(response?.body?.code)}; requestId=${safe(response?.requestId)}; traceId=${safe(response?.traceId)}`) + return response.body.data +} + +export async function withFlowJournal(path, binding, action) { + await mkdir(dirname(path), { recursive: true }) + const lock = await open(`${path}.lock`, 'wx', 0o600) + try { + let state + try { state = JSON.parse(await readFile(path, 'utf8')) } + catch (error) { + if (error.code !== 'ENOENT') throw new Error('全流程检查点损坏;禁止新建替代批次') + state = { version: 1, binding, stages: {}, requests: [], status: 'NEW' } + } + if (state.version !== 1 || state.binding !== binding) throw new Error('全流程检查点绑定变化') + const save = async patch => { + const next = { ...state, ...patch } + const file = await open(`${path}.next`, 'wx', 0o600) + try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() } + await rename(`${path}.next`, path) + Object.assign(state, next) + } + await save({}) + return await action(state, save) + } finally { await lock.close(); await unlink(`${path}.lock`) } +} + +async function bodyDigest(body) { + if (!(body instanceof FormData)) return hash(JSON.stringify(body ?? null)) + const parts = [] + for (const [key, value] of body) parts.push([key, typeof value === 'string' ? value + : { name: value.name, type: value.type, sha256: hash(Buffer.from(await value.arrayBuffer())) }]) + return hash(JSON.stringify(parts)) +} + +// Only public business HTTP. No fixture runner, SQL, deployment or automatic network retry. +export function createFlowContext({ settings, state, save, fetcher = fetch }) { + const sessions = [] + const context = { + marker: '', options: {}, context: {}, createdResources: {}, steps: [], + registerSession(session) { sessions.push(session) }, + async request(method, path, body, token = context.token, extraHeaders = {}) { + return send(method, path, body, token, extraHeaders, false) + }, + async requestWithBody(method, path, body, token = context.token, extraHeaders = {}) { + return send(method, path, body, token, extraHeaders, false) + }, + async requestMultipart(method, path, file, token = context.token, extraHeaders = {}) { + const form = file instanceof FormData ? file : new FormData() + if (!(file instanceof FormData)) { + if (!file || file.content === undefined) throw new Error('缺少上传内容') + form.append('file', new Blob([file.content], { type: file.type || 'application/octet-stream' }), file.name || 'acceptance.bin') + } + return send(method, path, form, token, extraHeaders, false) + }, + async requestBinary(method, path, token = context.token) { return send(method, path, undefined, token, {}, true) }, + apiSuccess(module, name, response) { + const data = requireSuccess(response) + context.steps.push({ module, name, passed: true }) + return data + }, + assert(module, name, response, condition) { + context.steps.push({ module, name, passed: Boolean(condition) }) + if (!condition) throw new Error(`${module}: ${name}`) + }, + async login(role) { + const data = requireSuccess(await send('POST', '/api/auth/login', settings.credentials[role], null, {}, false)) + if (!data?.accessToken || !data.refreshToken) throw new Error(`${role}: 登录令牌不完整`) + sessions.push(data) + return data + }, + async close() { + const failures = [] + for (const session of sessions) { + try { requireSuccess(await send('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken, {}, false)) } + catch { failures.push('SESSION_LOGOUT_FAILED') } + } + return failures + }, + } + async function send(method, path, body, token, extraHeaders, binary) { + if (!['GET', 'POST', 'PUT'].includes(method) || !/^\/api\/(auth\/(login|logout)$|platform\/|merchant\/|pda\/)/.test(path) + || path.includes('..') || path.includes('#')) throw new Error('全流程请求路径或方法不允许') + const auth = path === '/api/auth/login' || path === '/api/auth/logout' + if (path !== '/api/auth/logout' && state.requests.length >= 2500) throw new Error('全流程请求超过安全上限') + const entry = { sequence: state.requests.length + 1, method, path, startedAt: new Date().toISOString(), + status: 'INTENT', ...(auth ? {} : { bodyHash: await bodyDigest(body) }) } + await save({ requests: [...state.requests, entry] }) + let result + try { + const response = await fetcher(settings.apiBase.replace(/\/api$/, '') + path, { + method, redirect: 'error', signal: AbortSignal.timeout(45000), + headers: { ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}), + ...extraHeaders, ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + ...(body !== undefined ? { body: body instanceof FormData ? body : JSON.stringify(body) } : {}), + }) + if (binary && response.ok && !response.headers.get('content-type')?.includes('json')) { + const bytes = new Uint8Array(await response.arrayBuffer()) + result = { http: response.status, bytes, buffer: Buffer.from(bytes), contentType: response.headers.get('content-type'), headers: Object.fromEntries(response.headers) } + } else { + const envelope = await response.json() + result = { http: response.status, body: envelope, requestId: envelope?.requestId || response.headers.get('x-request-id'), + traceId: envelope?.traceId || response.headers.get('x-trace-id') } + } + } catch { + await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row, status: 'UNKNOWN' } : row) }) + throw new Error(`HTTP结果不确定:${method} ${path};禁止自动重试`) + } + await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row, + status: success(result) || result.bytes ? 'DONE' : 'REJECTED', http: result.http, + code: safe(result.body?.code), requestId: safe(result.requestId), traceId: safe(result.traceId) } : row) }) + return result + } + return context +} diff --git a/scripts/prod-release-http.test.mjs b/scripts/prod-release-http.test.mjs new file mode 100644 index 000000000..5ba67e6c3 --- /dev/null +++ b/scripts/prod-release-http.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createFlowContext, requireSuccess } from './prod-release-http.mjs' +const settings = { apiBase: 'https://fixture.invalid/api', credentials: { ADMIN: { loginId: 'fixture', password: 'private' } } } +function harness(fetcher, saveError = false) { + const state = { requests: [] }, saves = [] + const save = async patch => { if (saveError) throw Error('disk failed'); Object.assign(state,patch); saves.push(structuredClone(state)) } + return { state, saves, ctx: createFlowContext({ settings, state, save, fetcher }) } +} +test('persists intent before writes and never saves credentials or tokens', async () => { + let h + h = harness(async (_url, options) => { + assert.equal(h.state.requests.at(-1).status, 'INTENT') + return new Response(JSON.stringify({ code: '200', data: { accessToken: 'access-secret', refreshToken: 'refresh-secret' } })) + }) + await h.ctx.login('ADMIN') + await h.ctx.close() + assert.doesNotMatch(JSON.stringify(h.state), /private|access-secret|refresh-secret/) + assert.ok(h.state.requests.every(row=>row.status==='DONE')) +}) +test('disk failure prevents HTTP and unknown network result is never retried', async () => { + let calls=0 + const broken=harness(async()=>{calls++;throw Error('network')},true) + await assert.rejects(broken.ctx.request('POST','/api/platform/v1/example',{}),/disk/) + assert.equal(calls,0) + const h=harness(async()=>{calls++;throw Error('network')}) + await assert.rejects(h.ctx.request('POST','/api/platform/v1/example',{}),/结果不确定/) + assert.equal(calls,1) + assert.equal(h.state.requests[0].status,'UNKNOWN') +}) +test('business errors retain diagnostic IDs and binary responses preserve bytes',async()=>{ + const h=harness(async()=>new Response(JSON.stringify({code:'A0403',requestId:'req-1',traceId:'trace-1',message:'private'}))) + const response=await h.ctx.request('GET','/api/platform/v1/example') + assert.throws(()=>requireSuccess(response),/A0403.*req-1.*trace-1/) + assert.doesNotMatch(JSON.stringify(h.state),/private/) + const binary=harness(async()=>new Response('%PDF-fixture',{headers:{'content-type':'application/pdf'}})) + const pdf=await binary.ctx.requestBinary('POST','/api/platform/inbounds/1/carton-label-pdf') + assert.equal(Buffer.from(pdf.bytes).toString(),'%PDF-fixture') + assert.equal(pdf.contentType,'application/pdf') +}) + +test('request limit blocks business calls but permits session logout', async () => { + const h = harness(async () => new Response(JSON.stringify({ code: '200', data: {} }))) + h.ctx.registerSession({ accessToken: 'secret', refreshToken: 'refresh' }) + h.state.requests = Array.from({ length: 2500 }, (_, index) => ({ sequence: index + 1, status: 'DONE' })) + await assert.rejects(h.ctx.request('GET', '/api/platform/v1/example'), /安全上限/) + assert.deepEqual(await h.ctx.close(), []) + assert.equal(h.state.requests.at(-1).path, '/api/auth/logout') + assert.equal(h.state.requests.at(-1).status, 'DONE') +}) diff --git a/scripts/prod-release-measurement-plan.mjs b/scripts/prod-release-measurement-plan.mjs index c9ad7584c..8c2ef15ef 100644 --- a/scripts/prod-release-measurement-plan.mjs +++ b/scripts/prod-release-measurement-plan.mjs @@ -2,13 +2,13 @@ export const CARTON_PROFILE = Object.freeze({ lengthCm: 40, widthCm: 30, heightCm: 30, weightKg: 7.5 }) export function cartonTotals(packageCount) { - if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('生产冒烟仅允许 1~2 箱') + if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('单票冒烟仅允许 1~2 箱') return { packageCount, lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: packageCount * 7.5, volumeCbm: packageCount * 36 / 1000 } } export function normalizeOrderInput(input) { - if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('生产冒烟仅支持一个 SKU') + if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('单票冒烟仅支持一个 SKU') const cargo = input.payload.cargoes[0] const totals = cartonTotals(cargo.packageCount) if (cargo.netWeightKg != null && (!Number.isFinite(Number(cargo.netWeightKg)) diff --git a/scripts/prod-release-order-stage.mjs b/scripts/prod-release-order-stage.mjs index 5d54f8ea8..86260e464 100644 --- a/scripts/prod-release-order-stage.mjs +++ b/scripts/prod-release-order-stage.mjs @@ -36,6 +36,8 @@ export function validateOrderInput(input) { assert(payload.inboundCode == null && payload.senderAddressId == null && payload.privateDestinationAddressId == null, '不允许手工入仓码或地址副作用') assert(Array.isArray(payload.cargoes) && payload.cargoes.length === 1, '专属冒烟固定一个 SKU') const cargo = payload.cargoes[0] + assert(payload.shipmentId && payload.referenceId && cargo.shipmentId && cargo.referenceId, + 'FBA 订单及货物行均必须提供 Shipment ID 和 Reference ID') assert(Number.isInteger(cargo.packageCount) && cargo.packageCount >= 1 && cargo.packageCount <= 2, '冒烟仅允许 1~2 箱') assert(cargo.woodenProduct === false && cargo.dangerousGoods === false && !cargo.packingGroupNo, '仅支持非木制非危险单 SKU 货物') assert(longId(cargo.productImageFileId), '需要客户已上传产品图片,不生成虚假证明文件') @@ -58,7 +60,8 @@ function binding(input) { // The caller must retain this file between invocations. Never generate a new batch to retry a failed one. export async function withOrderJournal(path, input, action) { validateOrderInput(input) - assert(typeof input.environment === 'string' && input.environment.startsWith('https://'), '缺少环境绑定') + assert(['test', 'gray'].includes(input.environment?.mode) + && typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定') await mkdir(dirname(path), { recursive: true }) const lock = await open(`${path}.lock`, 'wx', 0o600) const temp = `${path}.next` diff --git a/scripts/prod-release-order-stage.test.mjs b/scripts/prod-release-order-stage.test.mjs index afa9b0ced..f9cbcad51 100644 --- a/scripts/prod-release-order-stage.test.mjs +++ b/scripts/prod-release-order-stage.test.mjs @@ -7,15 +7,17 @@ import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, valid import { executeOrder } from './prod-release-acceptance.mjs' function fixture() { - return { batch: 'release-fixture-001', environment: 'https://fixture.invalid/api', + return { batch: 'release-fixture-001', environment: { mode: 'gray', apiBase: 'https://fixture.invalid/api' }, operator: { merchant: 'fixture-client', admin: 'fixture-admin' }, customer: { id: '1', name: 'fixture', code: 'fixture' }, origin: { id: '2', code: 'CN-YIWU-01' }, destination: { id: '3', code: 'FIXTURE', type: 'FBA' }, payload: { customerName: 'fixture', receivingWarehouseId: '2', destinationWarehouseId: '3', + shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture', amazonWarehouseCode: 'FIXTURE', transportMode: 'SEA', destinationType: 'FBA', fulfillmentType: 'STANDARD', containerMode: 'LCL', pickupMethod: 'SELF_EXPRESS', channelCode: 'FIXTURE', pricingTaxMode: 'TAX_INCLUDED', customerRemark: 'RELEASE-ACCEPTANCE:release-fixture-001', cargoes: [{ skuNo: 'fixture-sku', packageCount: 2, unitsPerPackage: 1, totalQuantity: 2, + shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture', lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072, productImageFileId: '4', woodenProduct: false, dangerousGoods: false }] } } } @@ -143,7 +145,7 @@ test('uncertain approval that still reads submitted must not be resent', async t test('change in environment, account, payload or batch is blocked before HTTP', async t => { const path = await sandbox(t), input = fixture(), api = server(input) await run(path, input, api) - for (const change of [i => i.environment = 'https://other.invalid/api', i => i.operator.merchant = 'other', + for (const change of [i => i.environment.apiBase = 'https://other.invalid/api', i => i.environment.mode = 'test', i => i.operator.merchant = 'other', i => i.payload.channelCode = 'OTHER', i => { i.batch = 'release-fixture-002'; i.payload.customerRemark = `RELEASE-ACCEPTANCE:${i.batch}` }]) { const other = fixture(); change(other) const count = api.calls.length @@ -184,14 +186,15 @@ test('corrupt journal is preserved and fails closed', async t => { for (const quoteMismatch of [false, true]) { test(`entry integration: customer/profile/route checks and HTTP headers (quote mismatch=${quoteMismatch})`, async t => { const path = await sandbox(t), input = fixture(), api = server(input) - const settings = { cn: 'https://fixture.invalid/api', overseas: 'https://fixture.invalid/overseas-api', customer: input.customer, + const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: input.customer, credentials: Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role => [role, { loginId: role, password: 'secret' }])) } const fetcher = async (url, options) => { - const endpoint = new URL(url).pathname.replace(/^\/(overseas-api|api)/, '') + new URL(url).search + assert.ok(url.startsWith('https://fixture.invalid/api/')) + const endpoint = new URL(url).pathname.replace(/^\/api/, '') + new URL(url).search let data if (endpoint === '/auth/login') { const role = JSON.parse(options.body).loginId - data = { accessToken: role, overseasAccessToken: `${role}-overseas` } + data = { accessToken: role } } else if (endpoint.includes('/iam/')) { data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' } } else if (endpoint.includes('/warehouse-admin?')) { @@ -203,7 +206,7 @@ for (const quoteMismatch of [false, true]) { assert.equal(options.headers.Authorization, 'Bearer CN_PDA') data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }] } else if (endpoint.endsWith('/warehouse-profile')) { - assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas') + assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA') data = { warehouseId: '6', warehouseCode: 'US-LAX-01' } } else if (/\/platform\/v1\/(pallets|locations)\?/.test(endpoint)) { data = { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] } diff --git a/scripts/prod-release-overseas-stage.mjs b/scripts/prod-release-overseas-stage.mjs new file mode 100644 index 000000000..e936369ef --- /dev/null +++ b/scripts/prod-release-overseas-stage.mjs @@ -0,0 +1,283 @@ +import { fingerprint } from './prod-release-order-stage.mjs' +import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' + +const BASE = '/api/platform/v1/last-leg' +const PDA = '/api/pda/v1/last-leg' +const check = (ok, message) => { if (!ok) throw new Error(message) } +const id = value => value == null ? '' : String(value) +const query = (path, values) => `${path}?${new URLSearchParams(values)}` +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + +/** Each successful command is persisted; an interrupted command must be investigated, never resent. */ +export function overseasCommands(context, checkpoint) { + const { state, save } = checkpoint + const unwrap = response => { + check(response?.http === 200 && String(response?.body?.code) === '200', + `海外 HTTP=${response?.http}; code=${response?.body?.code}; requestId=${response?.body?.requestId}; traceId=${response?.body?.traceId}`) + return response.body.data + } + async function command(name, method, path, body, token = context.token, headers = {}, file = null) { + const binding = fingerprint({ method, path, body: body ?? null, headers, file }) + const previous = state.commands?.[name] + check(!previous || previous.binding === binding, `海外命令 ${name} 与检查点不符`) + if (previous?.status === 'DONE') return previous.result + check(!previous, `海外命令 ${name} 结果未知;停止重发并保留检查点`) + const persist = async value => { const commands = { ...state.commands, [name]: value }; await save({ commands }); state.commands = commands } + await persist({ binding, status: 'INTENT' }) + let response + if (file) { + const form = new FormData() + form.append('file', new Blob([PNG], { type: 'image/png' }), file) + response = await context.requestWithBody(method, path, form, token) + } else response = await context.request(method, path, body, token, headers) + const result = unwrap(response) + await persist({ binding, status: 'DONE', result }) + return result + } + return { command, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)), + readCurrentReceivingSession: async token => { + const response = await context.request('GET', `${PDA}/receiving-containers/current`, undefined, token) + // Only the initial current-session probe treats the service's absent-session business code as empty. + // Subsequent mandatory session reads and every other resource remain strict. + if (response?.http === 200 && String(response?.body?.code) === 'A0404') return null + return unwrap(response) + }, + lookup: async (path, body) => unwrap(await context.request('POST', path, body)) } +} + +export function validateOverseasInput(input, domestic) { + check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次') + check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票') + for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`) + for (const key of ['vehicleId', 'driverId']) check(/^[1-9]\d*$/.test(input.resources?.[key] || ''), `缺少既有 ${key}`) + check(Boolean(input.resources.carrierName), '缺少既有车队名称') + for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`) + for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`) + if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks) + && domestic.cartonMarks.length === input.packageCount && new Set(domestic.cartonMarks).size === input.packageCount, + '本票冻结箱码数量不符') +} + +/** Normal single-ticket path only. No fixture runners, account creation, or master-data mutation. */ +export async function runOverseasStage({ input, domestic, context, checkpoint }) { + validateOverseasInput(input, domestic) + check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话') + const { state, save } = checkpoint + check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点') + const { read, lookup, command, readCurrentReceivingSession } = overseasCommands(context, checkpoint) + const pdaToken = context.overseasPdaToken + const remark = `RELEASE-ACCEPTANCE:${input.batch}` + const key = name => `${input.batch}-${name}` + if (!state.schedule) { + const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), + expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), + arrival: new Date(Date.now() + 86400000).toISOString().slice(0, 19) } + await save({ schedule }); state.schedule = schedule + } + const schedule = state.schedule + const vehicle = await read(`${BASE}/master-data/vehicles/${input.resources.vehicleId}`) + const driver = await read(query(`${BASE}/fleet/drivers/${input.resources.driverId}`, { expectedEnd: schedule.expectedDriverEndAt })) + check(id(vehicle.id) === input.resources.vehicleId && vehicle.region === 'OVERSEAS' && vehicle.status === 'IN_SERVICE', '既有海外车辆不可用') + check(id(driver.id) === input.resources.driverId && driver.qualification?.canDispatch === true, '既有司机证照/审核不满足本次出车') + let sources + for (let attempt = 0; attempt < 60; attempt++) { + const page = await read(query(`${BASE}/orders`, { containerNo: domestic.containerNo, page: '1', size: '200' })) + sources = (page.records || []).filter(row => id(row.sourceContainerBookingId) === domestic.containerBookingId) + if (sources.length) break + if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000) + } + check(sources?.length === 1 && id(sources[0].customerId) === input.customerId + && id(sources[0].sourceInboundId) === domestic.sourceInboundId && sources[0].inboundCode === domestic.inboundCode + && id(sources[0].overseasWarehouseId) === input.warehouseId, '柜内尾程来源不属于本次唯一客户/单票/接柜仓') + const tailOrderId = id(sources[0].id) + const arrivalRows = await lookup(`${BASE}/orders/container-arrival-times/query`, { sourceContainerBookingIds: [domestic.containerBookingId] }) + const arrival = arrivalRows.find(row => id(row.sourceContainerBookingId) === domestic.containerBookingId) + check(arrival && Number.isSafeInteger(Number(arrival.version)), '柜级到仓版本缺失') + if (!arrival.estimatedWarehouseArrivalAt) await command('arrival', 'PUT', `${BASE}/orders/container-summaries/${domestic.containerBookingId}/expected-arrival`, + { estimatedWarehouseArrivalAt: schedule.arrival, expectedVersion: Number(arrival.version), remark }, undefined, { 'Idempotency-Key': key('arrival') }) + let plans = state.commands?.convert?.result + if (!plans) { + const preview = await read(query(`${BASE}/inbound-plans/container-preview`, { sourceContainerBookingId: domestic.containerBookingId })) + check(preview.ready === true && Number(preview.totalOrderCount) === 1 && Number(preview.pendingOrderCount) === 1, '本票计划不可转换或存在未登记计划') + plans = await command('convert', 'POST', `${BASE}/inbound-plans/convert-container`, { + sourceContainerBookingId: domestic.containerBookingId, snapshotVersion: Number(preview.snapshotVersion), snapshotHash: preview.snapshotHash, + }, undefined, { 'Idempotency-Key': key('convert') }) + } + check(plans?.length === 1 && plans[0].lines?.length > 0 && plans[0].lines.every(line => line.inboundCode === domestic.inboundCode) + && plans[0].pallets?.length === 1, '本票入仓计划/实体托盘不唯一') + const pallet = plans[0].pallets[0] + check(pallet.palletNo && pallet.warehousePalletId, '计划未生成实体托盘') + const receiving = `${PDA}/receiving-containers` + if (!state.commands?.receiveComplete) { + const current = await readCurrentReceivingSession(pdaToken) + check(!current || current.containerNo === domestic.containerNo && current.inbounds?.length === 1 + && current.inbounds[0].inboundCode === domestic.inboundCode, '海外 PDA 正在操作其他柜') + const detailPath = query(`${receiving}/${encodeURIComponent(domestic.containerNo)}`, { sourceContainerBookingId: domestic.containerBookingId }) + const detail = await read(detailPath, pdaToken) + check(id(detail.overseasWarehouseId) === input.warehouseId && detail.inbounds?.length === 1 + && detail.inbounds[0].inboundCode === domestic.inboundCode && Number(detail.totalPackages) === input.packageCount, '收货柜身份或箱数不符') + const destinationType = detail.inbounds[0].destinationType + check(['OVERSEAS', 'FBA'].includes(destinationType), '单票不支持此收货类型') + if (destinationType === 'OVERSEAS') check(domestic.cartonMarks?.length === input.packageCount, + 'OVERSEAS逐箱收货必须提供本票正式冻结箱码') + await command('prepare', 'POST', query(`${receiving}/${encodeURIComponent(domestic.containerNo)}/prepare`, { sourceContainerBookingId: domestic.containerBookingId }), { idempotencyKey: key('prepare') }, pdaToken) + const photos = [] + for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) { + const uploaded = await command(`photo-${role}`, 'POST', `${receiving}/current/unload-photos/files`, undefined, pdaToken, {}, `${input.batch}-SYNTHETIC-${role}.png`) + check(/^[1-9]\d*$/.test(id(uploaded.fileId)), '验收图片未返回文件ID') + photos.push({ role, fileId: id(uploaded.fileId) }) + } + await command('sign', 'POST', `${receiving}/current/sign`, { photos, idempotencyKey: key('sign') }, pdaToken) + const resolved = await command('resolve-pallet', 'POST', `${receiving}/current/pallets/resolve`, { palletCode: pallet.palletNo, inboundCode: domestic.inboundCode }, pdaToken) + check(resolved.compatible === true, '计划托盘不兼容') + // FBA receives cumulative physical counts on its source pallet; carton resolution is not a receiving prerequisite. + for (const [index, cartonMark] of (destinationType === 'OVERSEAS' ? domestic.cartonMarks : []).entries()) { + const carton = await command(`resolve-carton-${index}`, 'POST', `${receiving}/current/cartons/resolve`, { cartonMark }, pdaToken) + check(carton.inboundCode === domestic.inboundCode, '箱码未归属本票') + await command(`receive-${index}`, 'POST', `${receiving}/current/receipts/carton`, { cartonMark, palletCode: pallet.palletNo, idempotencyKey: key(`receive-${index}`) }, pdaToken) + } + if (destinationType === 'FBA') await command('receive-bulk', 'POST', `${receiving}/current/receipts/bulk`, { + inboundCode: domestic.inboundCode, palletCode: pallet.palletNo, packageCount: input.packageCount, idempotencyKey: key('receive-bulk'), + }, pdaToken) + const received = await read(`${receiving}/current`, pdaToken) + check(Number(received.pendingPackages) === 0 && Number(received.totalPackages) === input.packageCount, '本票收货数量未核平') + await command('receiveComplete', 'POST', `${receiving}/current/complete`, { idempotencyKey: key('receive-complete') }, pdaToken) + } + if (!state.commands?.emptyReturn) { + const drayagePath = `/api/platform/v1/terminal-drayages/${domestic.containerBookingId}` + let drayage + for (let attempt = 0; attempt < 60; attempt++) { + drayage = await read(drayagePath) + if (drayage.status === 'UNLOADED' && drayage.unloadingCompletedAt) break + if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000) + } + check(id(drayage.containerBookingId) === domestic.containerBookingId && drayage.status === 'UNLOADED' + && drayage.allowedActions?.includes('RECORD_EMPTY_RETURN'), '海外卸柜事实尚未回传国内码头拖车') + const unloaded = Date.parse(`${drayage.unloadingCompletedAt}Z`) + check(Number.isFinite(unloaded), '卸柜时间无效') + const returned = await command('emptyReturn', 'POST', `${drayagePath}/empty-return`, { + expectedVersion: Number(drayage.version), actualAt: new Date(unloaded + 3600000).toISOString().slice(0, 19), remark, + }, undefined, { 'Idempotency-Key': key('empty-return') }) + check(returned.status === 'EMPTY_RETURNED', '实际还柜未完成') + } + let dispatch = state.commands?.dispatch?.result + if (!dispatch) { + const candidates = await read(`${BASE}/dispatches/candidates?sourceStage=RECEIVED`) + const matches = candidates.filter(row => row.sourceStage === 'RECEIVED' && row.inboundCode === domestic.inboundCode + && id(row.customerId) === input.customerId && id(row.overseasWarehouseId) === input.warehouseId) + check(matches.length === 1 && Number(matches[0].packageCount) === input.packageCount, '本票排车候选不唯一或数量不符') + dispatch = await command('dispatch', 'POST', `${BASE}/dispatches/pre-arrange`, { planPalletIds: matches.map(row => id(row.planPalletId)) }) + } + const dispatchNo = dispatch.dispatchNo + check(Boolean(dispatchNo), '排车单号缺失') + const transport = { vehiclePlate: vehicle.plateNumber, driverName: driver.details?.name, + driverPhone: driver.details?.phone, carrierName: input.resources.carrierName, estimatedArrivalAt: schedule.arrival } + await command('dispatch-vehicle', 'PUT', `${BASE}/dispatches/${dispatchNo}`, transport) + const transfer = await command('transfer', 'POST', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), { + idempotencyKey: key('transfer'), plannedDeliveryAt: schedule.arrival, deliveryMethod: 'TRUCK_PALLET', + waybillNo: key('WAYBILL'), carrierName: transport.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: '15托', driverName: transport.driverName, driverPhone: transport.driverPhone, + vehiclePlate: transport.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark }], + }, context.merchantToken) + check(transfer.deliveries?.length === 1 && transfer.outboundNo, '本票未形成唯一出库/派送单') + let trip = await command('trip', 'POST', `${BASE}/fleet/trips`, { warehouseId: input.warehouseId, + plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, + stops: [{ name: '验收起运仓' }, { name: '待匹配本票目的地' }], remark, idempotencyKey: key('trip') }) + const tripNo = trip.trip.tripNo, tripPath = `${BASE}/fleet/trips/${tripNo}` + trip = await command('trip-assign', 'POST', `${tripPath}/assignment`, { recordVersion: trip.trip.recordVersion, + vehicleId: input.resources.vehicleId, driverId: input.resources.driverId, reason: remark }) + trip = await command('trip-bind', 'POST', `${tripPath}/dispatches/bind`, { recordVersion: trip.trip.recordVersion, dispatchNo, reason: remark }) + let stops = state.stops + if (!stops) { + const loading = await read(`${tripPath}/loading`) + check(loading.cargo?.length > 0 && loading.cargo.every(row => row.destinationKey), '本票装载缺少真实目的地') + stops = [{ name: '验收起运仓' }, ...new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])).values()] + await save({ stops }); state.stops = stops + } + trip = await command('trip-plan', 'PUT', `${tripPath}/plan`, { recordVersion: trip.trip.recordVersion, + plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops, reason: remark }) + trip = await command('trip-review', 'POST', `${tripPath}/dispatches/${dispatchNo}/review`, { recordVersion: trip.trip.recordVersion, decision: 'APPROVED' }) + const pdaDispatch = `${PDA}/dispatches/${dispatchNo}` + if (!state.commands?.pickComplete) { + const picking = await read(pdaDispatch, pdaToken) + const exactDispatch = await read(`${BASE}/dispatches/${dispatchNo}`) + check(exactDispatch.items?.length > 0 && exactDispatch.items.every(row => row.inboundCode === domestic.inboundCode) + && picking.items?.length === exactDispatch.items.length + && picking.items.every(row => exactDispatch.items.some(item => id(item.id) === id(row.dispatchItemId))), 'PDA拣货包含其他票') + for (const item of picking.items) { + let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + for (const position of item.positions || []) { + if (remaining <= 0) break + const packages = Math.min(remaining, Number(position.availablePackages)) + if (!(packages > 0)) continue + await command(`pick-${item.dispatchItemId}-${position.id}`, 'POST', `${pdaDispatch}/items/${item.dispatchItemId}/pick`, { + positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(`pick-${position.id}`), remark, + }, pdaToken) + remaining -= packages + } + check(remaining === 0, '本票库存不足以完成拣货') + } + await command('pickComplete', 'POST', `${pdaDispatch}/complete`, { idempotencyKey: key('pick-complete') }, pdaToken) + } + let loadingResult = state.commands?.loading?.result + if (!loadingResult) { + const loading = await read(`${tripPath}/loading`) + check(!loading.blockReason, '整车装载未就绪') + loadingResult = await command('loading', 'POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, + manifestHash: loading.manifestHash, pallets: controlledPalletMeasurements(loading.cargo, stops), reason: `${remark} 合成验收测量` }) + } + const reviewed = await command('loading-review', 'POST', `${tripPath}/loading-review`, { recordVersion: loadingResult.recordVersion, decision: 'APPROVED' }) + if (!state.commands?.depart) { + const ready = await read(pdaDispatch, pdaToken) + check(ready.status === 'COMPLETED' && ready.canDepart === true, 'PDA发车门禁未通过') + } + await command('depart', 'POST', `${pdaDispatch}/depart`, { recordVersion: reviewed.trip.recordVersion, idempotencyKey: key('depart'), remark }, pdaToken) + const outbound = await read(`${BASE}/outbound-orders/${transfer.outboundNo}`) + check(outbound.outbound?.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 + && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode), '本票未真实完成出库') + const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId, + tailOrderId, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: transfer.deliveries[0].deliveryNo, + tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' } + await save({ result }); return result +} + +/** Close only this ticket's trip after delivery; release its two resource claims without creating probe trips. */ +export async function finishSingleOrderTrip({ input, overseas, context, checkpoint }) { + check(overseas?.tripNo && overseas?.dispatchNo && overseas?.deliveryNo, '缺少本票车次与派送身份') + check(!Object.values(checkpoint.state.commands || {}).some(row => row.status !== 'DONE'), '车次结案命令结果未知;停止重发') + const { read, command } = overseasCommands(context, checkpoint) + const path = `${BASE}/fleet/trips/${encodeURIComponent(overseas.tripNo)}` + const delivery = await read(query(`${BASE}/delivery-orders/${encodeURIComponent(overseas.deliveryNo)}`, { customerId: input.customerId })) + check(delivery.order?.status === 'DELIVERED' && id(delivery.order.customerId) === input.customerId + && delivery.order.dispatchNo === overseas.dispatchNo, '本票尚未完成签收,不能结案车次') + const detail = await read(path) + check(detail.trip?.tripNo === overseas.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId + && id(detail.trip.driverId) === input.resources.driverId && detail.dispatches?.length === 1 + && detail.dispatches[0].dispatchNo === overseas.dispatchNo, '车次含其他票或资源身份不符') + let completed = checkpoint.state.commands?.tripComplete?.result + if (!completed) { + const completion = await read(`${path}/completion`) + check(completion.canComplete === true && completion.outcomes?.length > 0, '车次结案条件未通过') + completed = await command('tripComplete', 'POST', `${path}/complete`, { recordVersion: completion.recordVersion, + idempotencyKey: `${input.batch}-trip-complete`, reason: '单票验收实际签收完成' }) + } + check(completed.trip?.status === 'COMPLETED' && completed.trip.completedAt, '车次货物未结案') + const endLocation = detail.trip.stops?.at(-1)?.name + check(Boolean(endLocation), '本车次缺少结束地点') + const vehicle = await command('releaseVehicle', 'POST', `${path}/resources/release`, { + recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${input.batch}-vehicle-release`, + reason: '单票受控验收车辆在签收末站结束,无返程安排', + }) + const driver = await command('releaseDriver', 'POST', `${path}/resources/release`, { + recordVersion: vehicle.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: null, idempotencyKey: `${input.batch}-driver-release`, + reason: '单票受控验收司机在签收末站结束,无返程安排', + }) + check(driver.trip?.vehicleRelease && driver.trip?.driverRelease, '本车次车辆或司机未释放') + const final = await read(path) + check(final.trip?.status === 'COMPLETED' && final.trip.vehicleRelease && final.trip.driverRelease, '车次结案回查未通过') + const result = { tripNo: overseas.tripNo, status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } + await checkpoint.save({ result }); return result +} diff --git a/scripts/prod-release-overseas-stage.test.mjs b/scripts/prod-release-overseas-stage.test.mjs new file mode 100644 index 000000000..80cbf4dd6 --- /dev/null +++ b/scripts/prod-release-overseas-stage.test.mjs @@ -0,0 +1,158 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' + +const input = { batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1, + resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } } +const domestic = { containerBookingId: '5', sourceInboundId: '6', orderId: '7', containerNo: 'TEST0000001', + inboundCode: 'IN-TEST', orderNo: 'ORDER-TEST', cartonMarks: ['CARTON-TEST'] } +const ok = data => ({ http: 200, body: { code: '200', data } }) +const journal = () => { const state = {}; return { state, save: async patch => Object.assign(state, patch) } } + +test('reject multi-ticket quantities and duplicate frozen cartons before HTTP', () => { + assert.throws(() => validateOverseasInput({ ...input, packageCount: 3 }, domestic), /1~2/) + assert.throws(() => validateOverseasInput({ ...input, packageCount: 2 }, { ...domestic, cartonMarks: ['a', 'a'] }), /箱码/) +}) + +test('command records intent before HTTP; uncertain result blocks resend', async () => { + const checkpoint = journal(); let calls = 0 + const { command } = overseasCommands({ request: async () => { + calls++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw new Error('timeout') + } }, checkpoint) + await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /timeout/) + await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /结果未知/) + assert.equal(calls, 1) +}) + +test('completed command restores result without HTTP and rejects changed request', async () => { + const checkpoint = journal(); let calls = 0 + const { command } = overseasCommands({ request: async () => { calls++; return ok({ id: '99' }) } }, checkpoint) + await command('create', 'POST', '/example', { x: 1 }) + assert.deepEqual(await command('create', 'POST', '/example', { x: 1 }), { id: '99' }) + await assert.rejects(command('create', 'POST', '/example', { x: 2 }), /不符/) + assert.equal(calls, 1) +}) + +test('unknown prior command blocks even read/other writes on continuation', async () => { + const checkpoint = journal(); checkpoint.state.commands = { lost: { status: 'INTENT' } } + await assert.rejects(runOverseasStage({ input, domestic, checkpoint, + context: { overseasPdaToken: 'pda', merchantToken: 'merchant', request: () => assert.fail('must not call HTTP') } }), /结果未知/) +}) + +test('only initial current receiving probe accepts HTTP 200 A0404 as no session', async () => { + const calls = [] + const helpers = overseasCommands({ request: async (method, path, body, token) => { + calls.push({ method, path, token }); return { http: 200, body: { code: 'A0404' } } + } }, journal()) + assert.equal(await helpers.readCurrentReceivingSession('pda'), null) + assert.deepEqual(calls[0], { method: 'GET', path: '/api/pda/v1/last-leg/receiving-containers/current', token: 'pda' }) + await assert.rejects(helpers.read('/api/pda/v1/last-leg/receiving-containers/current', 'pda'), /A0404/) + await assert.rejects(helpers.read('/api/platform/v1/last-leg/orders/1'), /A0404/) +}) + +test('current receiving probe preserves auth, transport and other business errors', async () => { + for (const [http, code] of [[200, 'A0403'], [200, 'A0500'], [401, 'A0404'], [404, 'A0404'], [503, 'A0404']]) { + const helpers = overseasCommands({ request: async () => ({ http, body: { code, requestId: 'request-test' } }) }, journal()) + await assert.rejects(helpers.readCurrentReceivingSession('pda'), /request-test/) + } +}) + +function fixture({ foreignSource = false, destinationType = 'OVERSEAS' } = {}) { + const checkpoint = journal(), calls = [] + const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0, + inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] } + const trip = { trip: { tripNo: 'TRIP-TEST', recordVersion: 1, stops: [] } } + const cargo = [{ warehousePalletId: '9', palletNo: 'PLT-TEST', destinationKey: 'DEST', destinationLabel: 'Destination' }] + const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {}, + requestWithBody: async (method, path, form, token) => { + calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' }) + }, request: async (method, path, body, token) => { + calls.push({ method, path, body, token }) + if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' }) + if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: true }, details: { name: 'Driver', phone: '10000000000' } }) + if (path.includes('/orders?containerNo=')) return ok({ records: [{ sourceContainerBookingId: '5', customerId: foreignSource ? '99' : '1', sourceInboundId: '6', + inboundCode: domestic.inboundCode, overseasWarehouseId: '2', id: '8' }] }) + if (path.endsWith('/container-arrival-times/query')) return ok([{ sourceContainerBookingId: '5', version: 0 }]) + if (path.includes('/container-preview?')) return ok({ ready: true, totalOrderCount: 1, pendingOrderCount: 1, snapshotVersion: 1, snapshotHash: 'hash' }) + if (path.endsWith('/convert-container')) return ok([{ lines: [{ inboundCode: domestic.inboundCode }], pallets: [{ palletNo: 'PLT-TEST', warehousePalletId: '9' }] }]) + if (path.endsWith('/current') && method === 'GET') return calls.filter(c => c.path.endsWith('/current')).length === 1 + ? { http: 200, body: { code: 'A0404' } } : ok(session) + if (path.includes('/receiving-containers/TEST0000001?')) return ok(session) + if (path.endsWith('/pallets/resolve')) return ok({ compatible: true }) + if (path.endsWith('/cartons/resolve')) return ok({ inboundCode: domestic.inboundCode }) + if (path.endsWith('/terminal-drayages/5')) return ok({ containerBookingId: '5', status: 'UNLOADED', + version: 1, unloadingCompletedAt: '2026-10-12T12:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }) + if (path.endsWith('/empty-return')) return ok({ status: 'EMPTY_RETURNED' }) + if (path.endsWith('/candidates?sourceStage=RECEIVED')) return ok([{ sourceStage: 'RECEIVED', inboundCode: domestic.inboundCode, + customerId: '1', overseasWarehouseId: '2', packageCount: 1, planPalletId: '11' }]) + if (path.endsWith('/pre-arrange')) return ok({ dispatchNo: 'DISPATCH-TEST' }) + if (path.includes('/merchant/v1/last-leg/delivery-orders?')) return ok({ outboundNo: 'OUT-TEST', deliveries: [{ deliveryNo: 'DELIVERY-TEST' }] }) + if (path.endsWith('/loading') && method === 'GET') return ok({ cargo, recordVersion: 1, manifestHash: 'manifest' }) + if (path.endsWith('/loading') && method === 'POST') return ok({ recordVersion: 2 }) + if (path.includes('/fleet/trips')) return ok(trip) + if (path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST') return ok({ status: 'COMPLETED', canDepart: true, + items: [{ dispatchItemId: '12', scheduledPackages: 1, pickedPackages: 0, positions: [{ id: '13', availablePackages: 1 }] }] }) + if (path === '/api/platform/v1/last-leg/dispatches/DISPATCH-TEST' && method === 'GET') return ok({ items: [{ id: '12', inboundCode: domestic.inboundCode }] }) + if (path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED' }, pallets: [{ inboundCode: domestic.inboundCode }] }) + if (method === 'GET') assert.fail(`unexpected GET ${path}`) + return ok({}) + } } + return { input, domestic, context, checkpoint, calls } +} + +test('normal single-ticket HTTP chain reaches outbound with existing master resources and PDA identity', async () => { + const f = fixture(), result = await runOverseasStage(f) + assert.equal(result.status, 'OVERSEAS_OUTBOUNDED') + assert.equal(result.fullFlowVerified, false) + assert.equal(result.deliveryNo, 'DELIVERY-TEST') + assert.ok(f.calls.some(row => row.path.endsWith('/receipts/carton') && row.token === 'pda')) + assert.ok(f.calls.some(row => row.path.endsWith('/depart') && row.token === 'pda')) + assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1) + assert.equal(f.calls.filter(row => row.path.endsWith('/empty-return')).length, 1) + assert.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0) + assert.ok(Object.values(f.checkpoint.state.commands).every(row => row.status === 'DONE')) +}) + +test('foreign customer source stops before plan conversion or receiving', async () => { + const f = fixture({ foreignSource: true }) + await assert.rejects(runOverseasStage(f), /来源不属于/) + assert.equal(f.calls.filter(row => row.method !== 'GET').length, 0) +}) + +test('FBA receives cumulative package count without fabricated or resolved carton marks', async () => { + const f = fixture({ destinationType: 'FBA' }) + f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks + const result = await runOverseasStage(f) + assert.equal(result.status, 'OVERSEAS_OUTBOUNDED') + const received = f.calls.filter(row => row.path.endsWith('/receipts/bulk')) + assert.equal(received.length, 1) + assert.equal(received[0].body.packageCount, 1) + assert.equal(f.calls.filter(row => /\/cartons\/resolve|\/receipts\/carton/.test(row.path)).length, 0) +}) + +test('OVERSEAS missing frozen carton marks fails before opening receiving session', async () => { + const f = fixture(); f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks + await assert.rejects(runOverseasStage(f), /正式冻结箱码/) + assert.equal(f.calls.filter(row => /\/prepare\?|\/current\/sign|\/receipts\//.test(row.path)).length, 0) +}) + +test('signed single ticket closes its trip and releases only its existing resources', async () => { + const checkpoint = journal(), writes = [] + const overseas = { tripNo: 'TRIP-TEST', dispatchNo: 'DISPATCH-TEST', deliveryNo: 'DELIVERY-TEST' } + let trip = { tripNo: overseas.tripNo, vehicleId: '3', driverId: '4', recordVersion: 1, + stops: [{ name: 'Origin' }, { name: 'Destination' }] } + const context = { request: async (method, path, body) => { + if (path.includes('/delivery-orders/')) return ok({ order: { customerId: '1', status: 'DELIVERED', dispatchNo: overseas.dispatchNo } }) + if (path.endsWith('/completion')) return ok({ canComplete: true, recordVersion: 1, outcomes: [{}] }) + if (method === 'POST') { + writes.push({ path, body }); trip = { ...trip, recordVersion: trip.recordVersion + 1 } + if (path.endsWith('/complete')) trip = { ...trip, status: 'COMPLETED', completedAt: '2026-10-12T12:00:00Z' } + else if (body.resourceKind === 'VEHICLE') trip.vehicleRelease = { ended: true } + else trip.driverRelease = { ended: true } + } + return ok({ trip, dispatches: [{ dispatchNo: overseas.dispatchNo }] }) + } } + assert.equal((await finishSingleOrderTrip({ input, overseas, context, checkpoint })).status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + assert.equal(writes.length, 3) + assert.deepEqual(writes.map(row => row.body.recordVersion), [1, 2, 3]) +}) From 46507e41c6b69671c211200df9409542729cd572 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 12 Oct 2026 18:01:16 +0800 Subject: [PATCH 07/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- .../mapper/DispatchCandidateMapper.java | 4 +- .../dto/PalletOperationResponses.java | 9 ++- .../service/PdaPalletOperationService.java | 23 +++++- .../relabel/pda/dto/PdaRelabelResponses.java | 13 +++- .../pda/service/PdaRelabelService.java | 13 +++- .../dto/PdaReturnReceiptRequests.java | 11 ++- .../dto/ReturnCartonCountDtos.java | 10 ++- .../entity/ReturnInventoryCarton.java | 1 + .../returnreceipt/entity/ReturnReceipt.java | 4 + .../mapper/ReturnInventoryCartonMapper.java | 4 + .../service/ReturnCartonCountService.java | 6 +- .../service/ReturnInventoryService.java | 11 +++ .../service/ReturnReceiptService.java | 10 +++ .../src/main/resources/db/lastleg.sql | 4 + .../PdaPalletOperationServiceTest.java | 78 +++++++++++++++++++ .../pda/service/PdaRelabelServiceTest.java | 25 ++++++ .../service/ReturnReceiptServiceTest.java | 4 +- 17 files changed, 214 insertions(+), 16 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e8d326718..d21b02cd1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -258,11 +258,13 @@ public interface DispatchCandidateMapper { @Param("relabelNo") String relabelNo); @Select("") + int heartbeat(@Param("scope") long scope,@Param("account") boolean account,@Param("fence") long fence,@Param("now") LocalDateTime now); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java new file mode 100644 index 000000000..c48016583 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopInventorySnapshot; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopInventorySnapshotMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java new file mode 100644 index 000000000..117c462dd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundDiscoveryScope; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopOutboundDiscoveryScopeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java new file mode 100644 index 000000000..313161408 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopOutboundItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java new file mode 100644 index 000000000..15b8721ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundOrder; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopOutboundOrderMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java new file mode 100644 index 000000000..82ad4f828 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuDailySales; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSkuDailySalesMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java new file mode 100644 index 000000000..ed752bca3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuProfile; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSkuProfileMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java new file mode 100644 index 000000000..9d93655a4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDate; + +@Mapper +public interface SelfopSyncRunMapper extends BaseMapper { + /** Current read after acquiring the inventory scope lock, including after a competing publisher commits. */ + @Select("SELECT * FROM ll_selfop_sync_run WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND inventory_daily_sample_date=#{date} FOR UPDATE") + SelfopSyncRun dailyInventorySample(@Param("account") String account, @Param("warehouse") String warehouse, + @Param("date") LocalDate date); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java new file mode 100644 index 000000000..a937d1ade --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSyncScopeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java new file mode 100644 index 000000000..da118dc82 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncStage; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSyncStageMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java new file mode 100644 index 000000000..8fcdcbe2a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSyncTaskMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java new file mode 100644 index 000000000..8078209bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; +import java.util.Map; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Component +@ConfigurationProperties(prefix = "baiyue.self-operated-analytics") +public class SelfopAnalyticsProperties { + private String collectionMode = "OFF"; + private String environment = "test"; + private String exclusiveGrantId; + private LocalDateTime exclusiveGrantExpiresAtUtc; + private boolean exclusiveHandoffVerified = false; + private boolean priorEgressClosed = false; + private boolean inflightDrained = false; + private boolean transferredBudgetVerified = false; + private long transferredDailyHttpCount = 0; + private LocalDate transferredBudgetDate; + private LocalDateTime transferredCooldownUntilUtc; + private String transferredPauseReason; + private int dailyRequestBudget = 0; + private int businessMinRequestIntervalMs = 5000; + private int maxRateLimitFailures = 5; + private int cooldownSeconds = 60; + private int egressLeaseSeconds = 120; + private boolean retentionEnabled = false; + private int salesRetentionDays = 7; + private int persistenceBatchSize = 500; + private int minimumBuildIntervalSeconds = 1800; + /** Read-only UI projection; collectionMode is the sole runtime mode input. */ + public boolean isExecutionEnabled() { + return "VALIDATION".equals(collectionMode)||("BUSINESS".equals(collectionMode)&&"production".equals(environment)); + } + private boolean salesPublicationEnabled = false; + private String integrationBaseUrl = "http://integration-service"; + private String internalServiceToken = ""; + private String sourceAccount = ""; + private String sourceQueryTimezone; + private String quantityUnit; + private int requestBudget = 100; + private int timeBudgetSeconds = 120; + private int minRequestIntervalMs = 1100; + private int leaseSeconds = 180; + private int maxAttempts = 5; + private int pageSize = 100; + private int applyBatchSize = 100; + private Map warehouses = new LinkedHashMap<>(); + @Data + public static class Warehouse { + private String businessTimezone = "Asia/Shanghai"; + private String sourceTimezone = "Asia/Shanghai"; + private String outboundCron = "0 0 8,20 * * *"; + private String inventoryCron = "0 0 8 * * *"; + private int inventoryStaleAfterSeconds = 93600; + private int outboundStaleAfterSeconds = 93600; + private int reviewRequestBudget = 100; + private int reviewTimeBudgetSeconds = 120; + private int reviewTargetDays = 7; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java new file mode 100644 index 000000000..249a563ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.require; + +/** Purpose is persisted on every task/run. Unknown purpose is never treated as business. */ +public final class SelfopCollectionPolicy { + private SelfopCollectionPolicy() { } + public static void requireBusiness(SelfopSyncTask task) { + require(task != null && "BUSINESS".equals(task.getExecutionPurpose()) && task.getValidationRootTaskId()==null, + "SELFOP_BUSINESS_PURPOSE_REQUIRED"); + } + public static void requireBusiness(SelfopSyncRun run) { + require(run != null && "BUSINESS".equals(run.getExecutionPurpose()),"SELFOP_BUSINESS_PURPOSE_REQUIRED"); + } + public static void requireExecution(SelfopAnalyticsProperties config,String purpose) { + require("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose),"SELFOP_PURPOSE_INVALID"); + require(purpose.equals(config.getCollectionMode()),"SELFOP_COLLECTION_MODE_REJECTED"); + if("BUSINESS".equals(purpose))require("production".equals(config.getEnvironment()),"SELFOP_BUSINESS_ENVIRONMENT_REJECTED"); + } + public static String configuredPurpose(SelfopAnalyticsProperties config) { + String purpose=config.getCollectionMode();requireExecution(config,purpose);return purpose; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java new file mode 100644 index 000000000..0255e0a59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_inventory_snapshot") +public class SelfopInventorySnapshot { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long snapshotRunId; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String skuCode; + private Integer productQuality; + private String productName; + private BigDecimal physicalQuantity; + private BigDecimal availableQuantity; + private BigDecimal frozenQuantity; + private BigDecimal transitQuantity; + private String sourceUniqueKey; + private LocalDateTime observedAtUtc; + private String qualityStatus; + private String qualityReason; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java new file mode 100644 index 000000000..6eec5b877 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_outbound_discovery_scope") +public class SelfopOutboundDiscoveryScope { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private LocalDateTime catalogFromUtc; + private LocalDateTime catalogUntilUtc; + private Long nextSequence; + private Long nextObservationSequence; + private Long activeTaskId; + private String leaseOwner; + private LocalDateTime leaseUntilUtc; + private Long fenceToken; + private Integer requestBudget; + private Integer timeBudgetSeconds; + private Integer minRequestIntervalMs; + private String apiBudgetJson; + private LocalDateTime apiCooldownUntilUtc; + private String apiPauseReason; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java new file mode 100644 index 000000000..75da06354 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_outbound_item") +public class SelfopOutboundItem { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long orderId; + private String skuCode; + private String productName; + private BigDecimal actualQuantity; + private String quantityUnit; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java new file mode 100644 index 000000000..73d6482a6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_outbound_order") +public class SelfopOutboundOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String deliveryNo; + private String sourceNo; + private Integer sourceStatus; + private String sourceCreatedTime; + private String sourceOutboundTime; + private String sourceTimezone; + private LocalDateTime outboundAtUtc; + private LocalDate businessDate; + private String businessTimezone; + private Integer salesEligible; + private Long revisionNo; + private String contentHash; + private Long lastRunId; + private Long lastObservationSequence; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java new file mode 100644 index 000000000..2bf453e96 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.math.BigDecimal; +import java.util.List; + +public final class SelfopPayloads { + private SelfopPayloads() { } + public record Order(String sourceAccount, String customerCode, String warehouseCode, + String deliveryNo, String sourceNo, Integer sourceStatus, String sourceCreatedTime, + String sourceOutboundTime, List items) { } + public record Item(String skuCode, String productName, BigDecimal actualQuantity, String quantityUnit) { } + public record Stock(String sourceAccount, String customerCode, String warehouseCode, + String skuCode, Integer productQuality, String productName, BigDecimal physicalQuantity, + BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity, + String sourceUniqueKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java new file mode 100644 index 000000000..a70f05267 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +public final class SelfopProgressProjection { + private SelfopProgressProjection() { } + private static final Set PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY", + "VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED"); + /** Pure projection for callers already holding the scope/task/run locks and fence. */ + public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan) { + require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID"); + require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID"); + require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID"); + Map p=progress(task); + if(!Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()))p=new LinkedHashMap<>(); + String countScope=scope(task); Object started=p.get("stageStartedAt"); + if(!phase.equals(p.get("phase")) || started==null)started=instant(utcNow()); + p.put("attemptNo",run.getAttemptNo()); p.put("phase",phase); p.put("progressScope",countScope); + p.put("stageStartedAt",started); p.put("unit",unit); p.put("completed",completed); + p.put("total",total); p.put("totalKnown",total!=null); p.put("scanRound",scan); p.put("countBasis",Set.of("INVENTORY","BUILD").contains(phase)?"ATTEMPT_ONLY":"TASK_CUMULATIVE"); p.put("sliceFrom",instant(task.getQueryFromUtc())); p.put("sliceUntil",instant(task.getQueryUntilUtc())); p.put("stageFinishedAt",null); + // A zero denominator is meaningful evidence, but cannot produce a percentage. + p.put("phasePercent",total==null||total==0?null:BigDecimal.valueOf(completed).multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(total),1,RoundingMode.DOWN)); + List> stages=new ArrayList<>(); + if(p.get("stages") instanceof List prior)for(Object value:prior) { + var entry=map(value); + if(PHASES.contains(Objects.toString(entry.get("phase"),""))&&!phase.equals(entry.get("phase")))stages.add(entry); + } + var current=new LinkedHashMap(); + for(String key:List.of("phase","unit","completed","total","totalKnown","scanRound","progressScope","countBasis", + "attemptNo","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt","phasePercent"))current.put(key,p.get(key)); + stages.add(current);p.remove("phases");p.put("stages",stages); + task.setProgressJson(bounded(p));task.setProgressUpdatedAtUtc(utcNow());task.setUpdatedAt(utcNow()); + run.setLastPhase(phase); run.setHeartbeatAtUtc(utcNow()); run.setUpdatedAt(utcNow()); + } + + /** Pure terminal snapshot; historical attempt evidence is immutable. */ + public static void freezeRun(SelfopSyncTask task,SelfopSyncRun run) { + if(run.getProgressSnapshotJson()!=null)return; + if(task.getProgressJson()==null) { + var fallback=new LinkedHashMap();fallback.put("attemptNo",run.getAttemptNo()); + fallback.put("phase",run.getLastPhase()!=null&&PHASES.contains(run.getLastPhase())?run.getLastPhase():"WAITING"); + fallback.put("progressScope",scope(task));fallback.put("countBasis","TASK_CUMULATIVE");fallback.put("scanRound",0); + fallback.put("unit","NONE");fallback.put("completed",0);fallback.put("total",null);fallback.put("totalKnown",false); + fallback.put("sliceFrom",instant(task.getQueryFromUtc()));fallback.put("sliceUntil",instant(task.getQueryUntilUtc())); + fallback.put("stageStartedAt",instant(run.getFetchStartedAtUtc()));fallback.put("stageFinishedAt",null);fallback.put("stages",List.of()); + run.setProgressSnapshotJson(bounded(fallback)); + return; + } + var p=progress(task); + // A later attempt's projection must never be copied into an earlier historical run. + require(Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()),"SELFOP_PROGRESS_ATTEMPT_MISMATCH"); + run.setProgressSnapshotJson(bounded(p));run.setUpdatedAt(utcNow()); + } + + private static String bounded(Map p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;} + private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";} + @SuppressWarnings("unchecked") private static Map progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);} + @SuppressWarnings("unchecked") private static Map map(Object value){return value instanceof Map?new LinkedHashMap<>((Map)value):new LinkedHashMap<>();} + private static Long number(Object value){return value instanceof Number n?n.longValue():null;} + private static String instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java new file mode 100644 index 000000000..50b42c69d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +/** Collector-owned identity; no credentials or provider protocol live in this neutral model. */ +public record SelfopRequestContext(String sourceAccount,String executionPurpose,Long rootTaskId, + String sessionId,String exclusiveGrantId,Long taskId,Long runId) { + public static SelfopRequestContext forTask(SelfopSyncTask task,SelfopSyncRun run,SelfopAnalyticsProperties properties) { + Long root="VALIDATION".equals(task.getExecutionPurpose())?task.getValidationRootTaskId():task.getId(); + return new SelfopRequestContext(task.getSourceAccount(),task.getExecutionPurpose(),root, + "selfop-"+root,properties.getExclusiveGrantId(),task.getId(),run.getId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java new file mode 100644 index 000000000..06228e752 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import lombok.Data; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Durable directory cursor and effective immutable stage references. */ +@Data +public class SelfopRoutingManifest { + private int scan = 1; + private int nextPage = 1; + private Integer pages; + private Long total; + private boolean directoryComplete; + private Map objects = new LinkedHashMap<>(); + private Map firstScan = new LinkedHashMap<>(); + @Data + public static class Entry { + private String customerCode; + private String deliveryNo; + private String warehouseCode; + private Long directoryStageId; + private Long effectiveStageId; + private Long refreshTaskId; + private Long newestSeenSequence; + private Long appliedStageId; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java new file mode 100644 index 000000000..a28738009 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sku_daily_sales") +public class SelfopSkuDailySales { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long salesRunId; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String skuCode; + private LocalDate businessDate; + private BigDecimal outboundQuantity; + private Long outboundOrderCount; + private String quantityUnit; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java new file mode 100644 index 000000000..8cf455a7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sku_profile") +public class SelfopSkuProfile { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long profileRunId; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String skuCode; + private String productName; + private String quantityUnit; + private LocalDateTime firstSeenAtUtc; + private LocalDateTime earliestEvidenceAtUtc; + private String evidenceType; + private Long evidenceRunId; + private Long lastSeenRunId; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java new file mode 100644 index 000000000..f1d70465b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; + +public final class SelfopSupport { + private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private SelfopSupport() { } + public static LocalDateTime utcNow() { return LocalDateTime.now(ZoneOffset.UTC); } + public static String json(Object value) { + try { return JSON.writeValueAsString(value); } + catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); } + } + public static T read(String value, Class type) { + try { return JSON.readValue(value, type); } + catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); } + } + public static String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception e) { throw new IllegalStateException("SELFOP_HASH_FAILED"); } + } + public static void require(boolean valid, String code) { + if (!valid) throw new IllegalStateException(code); + } + public static String identity(String value, int max) { + require(value != null && !value.isBlank() && value.length() <= max, "SELFOP_IDENTITY_INVALID"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java new file mode 100644 index 000000000..fadebf6f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_run") +public class SelfopSyncRun { + private String executionPurpose; + private LocalDateTime buildStartedAtUtc; + private LocalDateTime supersededAtUtc; + private String progressSnapshotJson; + private LocalDateTime heartbeatAtUtc; + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long taskId; + private Long scopeId; + private String sourceAccount; + private String warehouseCode; + private String runRole; + private Long upstreamTaskId; + private Integer attemptNo; + private String scopeHash; + private String scopeSnapshotJson; + private String observedDimensionsJson; + private Long fenceToken; + private Long basePublishedRunId; + private Long referenceSalesRunId; + private String status; + private String lastPhase; + private LocalDateTime fetchStartedAtUtc; + private LocalDateTime fetchFinishedAtUtc; + private LocalDate inventoryDailySampleDate; + private String inventorySampleRuleVersion; + private LocalDateTime finishedAtUtc; + private LocalDateTime publishedAtUtc; + private Integer pageCount; + private Long sourceTotalCount; + private Long uniqueObjectCount; + private String coverageStatus; + private LocalDateTime coveredFromUtc; + private LocalDateTime coveredUntilUtc; + private LocalDate completeDayFrom; + private LocalDate completeDayUntil; + private Long appliedFactRevision; + private Long calculatedFactRevision; + private String errorCode; + private String errorMessage; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java new file mode 100644 index 000000000..7bebe65f9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_scope") +public class SelfopSyncScope { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private String warehouseCode; + private String dataFamily; + private String scopeJson; + private String scopeHash; + private String businessTimezone; + private String sourceTimezone; + private String scheduleCron; + private Integer staleAfterSeconds; + private String warehousePolicyRef; + private LocalDateTime initialFromUtc; + private LocalDateTime initialUntilUtc; + private LocalDateTime discoveryFromUtc; + private LocalDateTime discoveryUntilUtc; + private Long factRevision; + private Long publishedFactRevision; + private Integer rebuildRequired; + private LocalDateTime invalidatedAtUtc; + private Long publishedRunId; + private String leaseOwner; + private LocalDateTime leaseUntilUtc; + private Long fenceToken; + private Long knownOrderCursorId; + private Long knownOrderRoundMaxId; + private LocalDateTime knownOrderRoundStartedAtUtc; + private LocalDateTime knownOrderRoundFinishedAtUtc; + private LocalDateTime historyRoundFromUtc; + private LocalDateTime historyRoundUntilUtc; + private LocalDateTime historySliceCursorUtc; + private LocalDateTime historyRoundFinishedAtUtc; + private Integer reviewRequestBudget; + private Integer reviewTimeBudgetSeconds; + private Integer reviewTargetDays; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java new file mode 100644 index 000000000..35ce63485 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_stage") +public class SelfopSyncStage { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long runId; + private Long stageBatchId; + private Long lastFetchRunId; + private Long observationSequence; + private String warehouseCode; + private String previousWarehouseCode; + private String objectType; + private String objectKeyHash; + private Integer firstPageNo; + private LocalDateTime observedAtUtc; + private String contentHash; + private String payloadJson; + private String beforePayloadJson; + private String validationStatus; + private String validationCode; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java new file mode 100644 index 000000000..9bf50a686 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_task") +public class SelfopSyncTask { + private String taskNo; + private String executionPurpose; + private Long validationRootTaskId; + private String validationBudgetJson; + private Long resultPublishedRunId; + private String progressJson; + private LocalDateTime progressUpdatedAtUtc; + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private String taskRole; + private Long scopeId; + private String warehouseCode; + private Long discoveryScopeId; + private Long upstreamTaskId; + private Long discoverySequence; + private Long observationBaseline; + private String requestKey; + private String requestHash; + private String taskType; + private LocalDateTime plannedAtUtc; + private LocalDateTime queryFromUtc; + private LocalDateTime queryUntilUtc; + private String queryScopeJson; + private String routingManifestJson; + private String scopeHash; + private String status; + private String applyState; + private String affectedWarehouseCodesJson; + private LocalDateTime applyStartedAtUtc; + private LocalDateTime applyCompletedAtUtc; + private Integer attemptCount; + private LocalDateTime nextRetryAtUtc; + private String requestedBy; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java new file mode 100644 index 000000000..7469d3b6d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java @@ -0,0 +1,163 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */ +@Mapper +public interface OutboundAggregateMapper { + @Select(""" + SELECT COUNT(*) FROM ll_selfop_outbound_order o + LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id + LEFT JOIN ll_selfop_sync_stage s ON s.observation_sequence=o.last_observation_sequence + AND s.warehouse_code=o.warehouse_code AND s.object_type='OUTBOUND_ORDER' + AND s.validation_status='VALID' + AND EXISTS (SELECT 1 FROM ll_selfop_sync_run origin WHERE origin.id=s.run_id AND origin.source_account=o.source_account) + LEFT JOIN ll_selfop_sync_run r ON r.id=s.run_id AND r.source_account=o.source_account + LEFT JOIN ll_selfop_sync_task t ON t.id=r.task_id + WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1 + AND (o.outbound_at_utc IS NULL OR o.business_date IS NULL + OR o.source_timezone IS NULL OR o.source_timezone != #{sourceTimezone} + OR o.business_timezone IS NULL OR o.business_timezone != #{businessTimezone} + OR i.id IS NULL OR i.actual_quantity IS NULL OR i.actual_quantity < 0 + OR i.quantity_unit IS NULL OR i.quantity_unit != #{unit} + OR r.id IS NULL OR r.execution_purpose IS NULL OR r.execution_purpose != 'BUSINESS' + OR t.execution_purpose IS NULL OR t.execution_purpose != 'BUSINESS' OR t.validation_root_task_id IS NOT NULL) + """) + long invalidFacts(@Param("account") String account, @Param("warehouse") String warehouse, + @Param("sourceTimezone") String sourceTimezone, @Param("businessTimezone") String businessTimezone, + @Param("unit") String unit); + + @Select(""" + + """) + List dailyBatch(@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku,@Param("date") LocalDate date); + + @Select(""" + + """) + List profileBatch(@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku); + + @Select(""" + + """) + List publishedProfileBatch(@Param("runId") Long runId,@Param("customer") String customer,@Param("sku") String sku); + + @Insert(""" + + """) + int insertDaily(@Param("rows") List rows); + + @Insert(""" + + """) + int insertProfiles(@Param("rows") List rows); + + @Select(""" + + """) + List coverageBatch(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash, + @Param("from") LocalDateTime from,@Param("id") Long id); + + @Select(""" + + """) + SelfopSyncRun contiguousCoverage(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash, + @Param("initial") LocalDateTime initial,@Param("discoveryFrom") LocalDateTime discoveryFrom, + @Param("discoveryUntil") LocalDateTime discoveryUntil); + + @Select(""" + SELECT COUNT(*) FROM ll_selfop_outbound_order WHERE source_account=#{account} AND warehouse_code=#{warehouse} + AND sales_eligible=1 AND updated_at >= #{buildStarted} + """) + long observationsChangedSince(@Param("account") String account,@Param("warehouse") String warehouse, + @Param("buildStarted") LocalDateTime buildStarted); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java new file mode 100644 index 000000000..cb3fd7b08 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.*; + +/** Orchestration has no encompassing transaction: checkpoints and heartbeats commit after every batch. */ +@Service +@RequiredArgsConstructor +public class OutboundCandidateBuilder { + private final OutboundCandidateTransaction transaction; + public void build(Long scopeId,Long runId,String owner,long fence) { + OutboundCandidateTransaction.Checkpoint checkpoint=transaction.begin(scopeId,runId,owner,fence); + while(!"FINISH".equals(checkpoint.phase()) && !"DONE".equals(checkpoint.phase()))checkpoint=transaction.batch(scopeId,runId,owner,fence); + if("FINISH".equals(checkpoint.phase()))transaction.finish(scopeId,runId,owner,fence); + } + public Long reusable(Long scopeId,Long runId,String owner,long fence) {return transaction.reusable(scopeId,runId,owner,fence);} + public static boolean regularBuildDue(SelfopSyncTask task,LocalDateTime previousBuild,LocalDateTime now) { + return "REBUILD".equals(task.getTaskType()) || "RECHECK".equals(task.getTaskType()) || task.getAttemptCount()!=null && task.getAttemptCount()>1 + || previousBuild==null || !now.isBefore(previousBuild.plusMinutes(30)); + } + public static Coverage contiguous(LocalDateTime initialFrom,List intervals) { + if(initialFrom==null)return new Coverage(null,null);LocalDateTime until=initialFrom; + for(Coverage interval:intervals.stream().filter(i->i.from()!=null && i.until()!=null && i.from().isBefore(i.until())) + .sorted(Comparator.comparing(Coverage::from)).toList()) { + if(interval.from().isAfter(until))break;if(interval.until().isAfter(until))until=interval.until(); + } + return until.equals(initialFrom)?new Coverage(null,null):new Coverage(initialFrom,until); + } + public record Coverage(LocalDateTime from,LocalDateTime until) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java new file mode 100644 index 000000000..7214b3d35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java @@ -0,0 +1,159 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; + +/** Each public method is one committed bounded transaction, invoked through Spring's proxy by the facade. */ +@Service +@RequiredArgsConstructor +public class OutboundCandidateTransaction { + private final OutboundLockMapper locks; + private final OutboundFactService facts; + private final SelfopSyncRunMapper runs; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncScopeMapper scopes; + private final SelfopSkuDailySalesMapper daily; + private final SelfopSkuProfileMapper profiles; + private final OutboundAggregateMapper aggregates; + private final SelfopAnalyticsProperties config; + + public record Checkpoint(Long runId,String phase,String customer,String sku,LocalDate date,long dailyRows,long profileRows,long committedBatches) { } + private record Held(SelfopSyncScope scope,SelfopSyncRun run,SelfopSyncTask task) { } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public Checkpoint begin(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,false); + SelfopSyncRun run=held.run(); SelfopSyncScope scope=held.scope(); + SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(run.getStatus()),"CANDIDATE_RUN_INVALID"); + SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED"); + facts.assertNoBarrier(scope); validateFacts(scope); + if("READY".equals(run.getStatus())) return new Checkpoint(runId,"DONE",null,null,null,0,0,0); + Checkpoint existing=checkpoint(held.task()); + if(run.getBuildStartedAtUtc()!=null && existing!=null && Objects.equals(existing.runId(),runId)) { + SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED"); + SelfopSupport.require(aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"CANDIDATE_OBSERVATION_CHANGED"); + return existing; + } + SelfopSupport.require(run.getBuildStartedAtUtc()==null,"CANDIDATE_CHECKPOINT_MISSING"); + // A new attempt has a new run ID; failed attempts are never overwritten or emptied. + daily.delete(new QueryWrapper().eq("sales_run_id",runId)); + profiles.delete(new QueryWrapper().eq("profile_run_id",runId)); + OutboundCandidateBuilder.Coverage coverage=coverage(scope); + run.setBasePublishedRunId(scope.getPublishedRunId()); + run.setCoveredFromUtc(coverage.from());run.setCoveredUntilUtc(coverage.until()); + OutboundPolicy.DayCoverage days=OutboundPolicy.completeDays(coverage.from(),coverage.until(),scope.getBusinessTimezone()); + run.setCompleteDayFrom(days.from());run.setCompleteDayUntil(days.until());run.setCoverageStatus(coverage.until()==null?"UNKNOWN":"COMPLETE"); + run.setCalculatedFactRevision(scope.getFactRevision());run.setBuildStartedAtUtc(SelfopSupport.utcNow());run.setStatus("BUILDING"); + Checkpoint result=new Checkpoint(runId,"DAILY",null,null,null,0,0,0);save(held,result);return result; + } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public Checkpoint batch(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,true); Checkpoint cursor=checkpoint(held.task()); + SelfopSupport.require(cursor!=null && Objects.equals(runId,cursor.runId()),"CANDIDATE_CHECKPOINT_MISSING"); + Checkpoint next; + if("DAILY".equals(cursor.phase())) { + List rows=aggregates.dailyBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku(),cursor.date()); + SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT"); + if(rows.isEmpty()) next=new Checkpoint(runId,"PROFILE",null,null,null,cursor.dailyRows(),0,cursor.committedBatches()); + else { + for(var row:rows){row.setId(IdWorker.getId());row.setSalesRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());} + aggregates.insertDaily(rows);var last=rows.get(rows.size()-1); + SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()) || !Objects.equals(cursor.date(),last.getBusinessDate()),"CANDIDATE_CURSOR_STALLED"); + next=new Checkpoint(runId,"DAILY",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate(),cursor.dailyRows()+rows.size(),0,cursor.committedBatches()+1); + } + } else if("PROFILE".equals(cursor.phase())) { + List rows=aggregates.profileBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku()); + SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT"); + if(rows.isEmpty()) next=new Checkpoint(runId,"FINISH",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches()); + else { + for(var row:rows){row.setId(IdWorker.getId());row.setProfileRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setEvidenceType("VALID_OUTBOUND");row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());} + aggregates.insertProfiles(rows);var last=rows.get(rows.size()-1); + SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()),"CANDIDATE_CURSOR_STALLED"); + next=new Checkpoint(runId,"PROFILE",last.getCustomerCode(),last.getSkuCode(),null,cursor.dailyRows(),cursor.profileRows()+rows.size(),cursor.committedBatches()+1); + } + } else {SelfopSupport.require("FINISH".equals(cursor.phase()),"CANDIDATE_PHASE_INVALID");return cursor;} + save(held,next);return next; + } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public void finish(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,true);Checkpoint cursor=checkpoint(held.task()); + SelfopSupport.require(cursor!=null && Objects.equals(cursor.runId(),runId) && "FINISH".equals(cursor.phase()),"CANDIDATE_NOT_COMPLETE"); + SelfopSupport.require(daily.selectCount(new QueryWrapper().eq("sales_run_id",runId))==cursor.dailyRows() + && profiles.selectCount(new QueryWrapper().eq("profile_run_id",runId))==cursor.profileRows(),"CANDIDATE_ROWS_INCOMPLETE"); + facts.assertNoBarrier(held.scope());held.run().setStatus("READY");save(held,new Checkpoint(runId,"DONE",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches())); + } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public Long reusable(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,false);SelfopSyncScope scope=held.scope(); + SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(held.run().getStatus()),"CANDIDATE_RUN_INVALID"); + SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED"); + facts.assertNoBarrier(scope);validateFacts(scope); + if(scope.getPublishedRunId()==null)return null; + SelfopSyncRun published=runs.selectById(scope.getPublishedRunId()); + if(published==null || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose()) + || published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash()) + || !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision()) + || !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null; + OutboundCandidateBuilder.Coverage next=coverage(scope); + if(!Objects.equals(next.from(),published.getCoveredFromUtc()) || !Objects.equals(next.until(),published.getCoveredUntilUtc()))return null; + // Content-identical accepted details still change receipt/run evidence; conservatively rebuild them. + return aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),published.getBuildStartedAtUtc())==0?published.getId():null; + } + + private Held held(Long scopeId,Long runId,String owner,long fence,boolean building) { + SelfopSyncScope scope=locks.scope(scopeId);LocalDateTime now=SelfopSupport.utcNow(); + SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()) && Objects.equals(scope.getLeaseOwner(),owner) && Objects.equals(scope.getFenceToken(),fence) + && scope.getLeaseUntilUtc()!=null && scope.getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED"); + SelfopSyncRun run=locks.run(runId);SelfopCollectionPolicy.requireBusiness(run); + SelfopSyncTask task=locks.task(run.getTaskId());SelfopCollectionPolicy.requireBusiness(task); + SelfopSupport.require(Objects.equals(scopeId,run.getScopeId()) && Objects.equals(run.getFenceToken(),fence) && Objects.equals(scope.getScopeHash(),run.getScopeHash()) + && "WAREHOUSE_SYNC".equals(run.getRunRole()) && Objects.equals(scopeId,task.getScopeId()) && Objects.equals(scope.getSourceAccount(),task.getSourceAccount()) + && Objects.equals(scope.getWarehouseCode(),task.getWarehouseCode()) && Objects.equals(scope.getScopeHash(),task.getScopeHash()),"CANDIDATE_RUN_INVALID"); + SelfopSupport.require(run.getStatus()!=null,"CANDIDATE_RUN_INVALID"); + if(building) { + SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED"); + SelfopSupport.require(run.getBuildStartedAtUtc()!=null && aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0, + "CANDIDATE_OBSERVATION_CHANGED"); + } + scope.setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));scope.setUpdatedAt(now);scopes.updateById(scope); + return new Held(scope,run,task); + } + private void validateFacts(SelfopSyncScope scope) { + Map settings=SelfopSupport.read(scope.getScopeJson(),Map.class);String unit=settings.get("quantityUnit") instanceof String s?s:null; + SelfopSupport.require(scope.getSourceTimezone()!=null && !scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED"); + SelfopSupport.require(unit!=null && !unit.isBlank(),"QUANTITY_UNIT_UNCONFIRMED"); + SelfopSupport.require(aggregates.invalidFacts(scope.getSourceAccount(),scope.getWarehouseCode(),scope.getSourceTimezone(),scope.getBusinessTimezone(),unit)==0,"FACT_TIME_UNIT_OR_EVIDENCE_INVALID"); + } + private OutboundCandidateBuilder.Coverage coverage(SelfopSyncScope scope) { + if(scope.getInitialFromUtc()==null)return new OutboundCandidateBuilder.Coverage(null,null); + SelfopSyncRun result=aggregates.contiguousCoverage(scope.getId(),scope.getScopeHash(),scope.getInitialFromUtc(),scope.getDiscoveryFromUtc(),scope.getDiscoveryUntilUtc()); + return result==null?new OutboundCandidateBuilder.Coverage(null,null):new OutboundCandidateBuilder.Coverage(result.getCoveredFromUtc(),result.getCoveredUntilUtc()); + } + private Checkpoint checkpoint(SelfopSyncTask task) { + if(task.getProgressJson()==null)return null;Map progress=SelfopSupport.read(task.getProgressJson(),Map.class);Object value=progress.get("candidateBuild"); + return value==null?null:SelfopSupport.read(SelfopSupport.json(value),Checkpoint.class); + } + private void save(Held held,Checkpoint checkpoint) { + LocalDateTime now=SelfopSupport.utcNow(); + SelfopSupport.require(held.scope().getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED"); + held.scope().setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));held.scope().setUpdatedAt(now);scopes.updateById(held.scope()); + Map progress=new LinkedHashMap<>(); + if(held.task().getProgressJson()!=null)SelfopSupport.read(held.task().getProgressJson(),Map.class).forEach((k,v)->progress.put(String.valueOf(k),v)); + if(!(progress.get("attemptNo") instanceof Number attempt) || !Objects.equals(attempt.intValue(),held.run().getAttemptNo()))progress.clear(); + progress.put("attemptNo",held.run().getAttemptNo());progress.put("candidateBuild",checkpoint);progress.remove("processedCount"); + held.task().setProgressJson(SelfopSupport.json(progress)); + SelfopProgressProjection.applyCheckpoint(held.task(),held.run(),"BUILD","BATCH",checkpoint.committedBatches(),null,0); + tasks.updateById(held.task());runs.updateById(held.run()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java new file mode 100644 index 000000000..82f6c33f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java @@ -0,0 +1,245 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; + +/** Short bounded transactions; all source requests must finish before entering this service. */ +@Service +@RequiredArgsConstructor +public class OutboundFactService { + private final OutboundLockMapper locks; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopSyncScopeMapper scopes; + private final SelfopSyncStageMapper stages; + private final SelfopOutboundOrderMapper orders; + private final SelfopOutboundItemMapper items; + + public record Lease(String owner, long fence) {} + + @Transactional(rollbackFor = Exception.class) + public void merge(Long taskId, Long runId, List stageIds, Map leases) { + SelfopSupport.require(stageIds != null && stageIds.size() <= 100, "MERGE_BATCH_LIMIT"); + SelfopSyncTask preview = tasks.selectById(taskId); + SelfopSupport.require(preview != null, "TASK_MISSING"); + SelfopCollectionPolicy.requireBusiness(preview); + // Discover the entire old/new lock set before touching facts. Recheck after order locks. + SortedSet warehouses = affected(preview); + warehouses.add(preview.getWarehouseCode()); + // Coordinator acquired this full old/new set from the entire stable routing manifest, + // not only this bounded segment. Register it before the first fact mutation. + warehouses.addAll(leases.keySet()); + for (Long id : stageIds) { + SelfopSyncStage s = stages.selectById(id); + SelfopSupport.require(s != null, "STAGE_MISSING"); + SelfopPayloads.Order p = SelfopSupport.read(s.getPayloadJson(),SelfopPayloads.Order.class); + warehouses.add(p.warehouseCode()); + SelfopOutboundOrder old = findOrder(p); + if (old != null) warehouses.add(old.getWarehouseCode()); + } + Map held = lockScopes(preview.getSourceAccount(),warehouses,leases); + SelfopSyncTask task = locks.task(taskId); + SelfopSyncRun run = locks.run(runId); + validateTaskRun(task,run,held); + SelfopRoutingManifest manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + SelfopSupport.require(!"APPLIED".equals(task.getApplyState()), "TASK_ALREADY_APPLIED"); + task.setAffectedWarehouseCodesJson(SelfopSupport.json(warehouses)); + task.setApplyState("APPLYING"); + if (task.getApplyStartedAtUtc() == null) task.setApplyStartedAtUtc(SelfopSupport.utcNow()); + task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task); + for (Long id : stageIds) { + SelfopSyncStage stage=locks.stage(id); applyOne(task,run,stage,held,manifest); + manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId()); + } + task.setRoutingManifestJson(SelfopSupport.json(manifest)); + mergeProgress(task,run,manifest);tasks.updateById(task); + run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision()); + run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run); + } + + /** The caller supplies the stable complete routing set, including refreshed effective references. */ + @Transactional(rollbackFor = Exception.class) + public void finish(Long taskId, Long runId, List allEffectiveStageIds, Map leases) { + SelfopSyncTask preview = tasks.selectById(taskId); + SelfopSupport.require(preview != null,"TASK_MISSING"); + SelfopCollectionPolicy.requireBusiness(preview); + SortedSet warehouseSet = affected(preview); warehouseSet.add(preview.getWarehouseCode()); + Map held = lockScopes(preview.getSourceAccount(),warehouseSet,leases); + SelfopSyncTask task = locks.task(taskId); SelfopSyncRun run = locks.run(runId); + validateTaskRun(task,run,held); + SelfopSupport.require(allEffectiveStageIds != null && new HashSet<>(allEffectiveStageIds).size() == allEffectiveStageIds.size(),"ROUTING_SET_INVALID"); + SelfopRoutingManifest manifest = SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + SelfopSupport.require(manifest.isDirectoryComplete(),"CATALOG_NOT_COMPLETE"); + Set required = new HashSet<>(); + for(SelfopRoutingManifest.Entry entry : manifest.getObjects().values()) { + SelfopSupport.require(entry.getEffectiveStageId() != null,"DETAIL_RECHECK_REQUIRED"); + required.add(entry.getEffectiveStageId()); + } + SelfopSupport.require(required.equals(new HashSet<>(allEffectiveStageIds)),"ROUTING_SET_INCOMPLETE"); + for (Long id : allEffectiveStageIds) { + SelfopSyncStage stage = locks.stage(id); + SelfopPayloads.Order p = validateStage(task, stage,manifest); + SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo()); + SelfopSupport.require(order != null && Objects.equals(order.getLastObservationSequence(),stage.getObservationSequence()) + && Objects.equals(order.getContentHash(),factHash(p,held.get(p.warehouseCode()))),"OBJECT_NOT_APPLIED"); + manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId()); + } + task.setApplyState("APPLIED"); task.setApplyCompletedAtUtc(SelfopSupport.utcNow()); + task.setRoutingManifestJson(SelfopSupport.json(manifest));mergeProgress(task,run,manifest); + task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task); + run.setStatus("FACTS_APPLIED"); + run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision()); + run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run); + } + + public void assertNoBarrier(SelfopSyncScope scope) { + SelfopSupport.require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode()).isEmpty(),"HALF_APPLIED_FACTS"); + } + + private void applyOne(SelfopSyncTask task, SelfopSyncRun run, SelfopSyncStage stage, Map held,SelfopRoutingManifest manifest) { + SelfopPayloads.Order p = validateStage(task,stage,manifest); + SelfopSyncScope target = held.get(p.warehouseCode()); + SelfopSupport.require(target != null,"LOCK_SET_CHANGED"); + LocalDateTime utc = OutboundPolicy.outboundUtc(p,target); + String unit = unit(target); + for(SelfopPayloads.Item item : p.items()) SelfopSupport.require(Objects.equals(unit,item.quantityUnit()),"QUANTITY_UNIT_UNCONFIRMED"); + SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo()); + SelfopSupport.require(order == null || held.containsKey(order.getWarehouseCode()),"LOCK_SET_CHANGED"); + SelfopSupport.require(order == null || order.getLastObservationSequence() <= stage.getObservationSequence(),"DETAIL_RECHECK_REQUIRED"); + String hash = factHash(p,target); + if(order != null && Objects.equals(order.getContentHash(),hash)) { + order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence()); + order.setUpdatedAt(SelfopSupport.utcNow()); orders.updateById(order); return; + } + SelfopSupport.require(order == null || order.getLastObservationSequence() < stage.getObservationSequence(),"OBSERVATION_CONTENT_CONFLICT"); + String oldWarehouse = order == null ? null : order.getWarehouseCode(); + if(order != null && stage.getBeforePayloadJson() == null) { + Map before = new LinkedHashMap<>(); before.put("order",order); + before.put("items",items.selectList(new QueryWrapper().eq("order_id",order.getId()).orderByAsc("sku_code"))); + stages.update(null,new UpdateWrapper().eq("id",stage.getId()) + .isNull("before_payload_json").set("before_payload_json",SelfopSupport.json(before)).set("updated_at",SelfopSupport.utcNow())); + } + boolean fresh = order == null; + if(fresh) { order = new SelfopOutboundOrder(); order.setCreatedAt(SelfopSupport.utcNow()); order.setRevisionNo(1L); } + else order.setRevisionNo(order.getRevisionNo()+1); + order.setSourceAccount(p.sourceAccount()); order.setCustomerCode(p.customerCode()); order.setWarehouseCode(p.warehouseCode()); + order.setDeliveryNo(p.deliveryNo()); order.setSourceNo(p.sourceNo()); order.setSourceStatus(p.sourceStatus()); + order.setSourceCreatedTime(p.sourceCreatedTime()); order.setSourceOutboundTime(p.sourceOutboundTime()); + order.setSourceTimezone(target.getSourceTimezone()); order.setBusinessTimezone(target.getBusinessTimezone()); + order.setOutboundAtUtc(utc); order.setBusinessDate(utc == null ? null : utc.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of(target.getBusinessTimezone())).toLocalDate()); + order.setSalesEligible(Integer.valueOf(100).equals(p.sourceStatus()) ? 1 : 0); + order.setContentHash(hash); order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence()); + order.setUpdatedAt(SelfopSupport.utcNow()); + if(fresh) orders.insert(order); + else { + orders.updateById(order); + // Explicit nulls are meaningful for cancellation and corrections; MyBatis default ignores null entity fields. + orders.update(null,new UpdateWrapper().eq("id",order.getId()) + .set("outbound_at_utc",order.getOutboundAtUtc()).set("business_date",order.getBusinessDate()) + .set("source_no",order.getSourceNo()).set("source_created_time",order.getSourceCreatedTime()) + .set("source_outbound_time",order.getSourceOutboundTime())); + items.delete(new QueryWrapper().eq("order_id",order.getId())); + } + for (SelfopPayloads.Item value : p.items()) { + SelfopOutboundItem item = new SelfopOutboundItem(); item.setOrderId(order.getId()); item.setSkuCode(value.skuCode()); + item.setProductName(value.productName()); item.setActualQuantity(value.actualQuantity()); item.setQuantityUnit(value.quantityUnit()); + item.setCreatedAt(SelfopSupport.utcNow()); item.setUpdatedAt(item.getCreatedAt()); items.insert(item); + } + Set changed = new HashSet<>(); changed.add(p.warehouseCode()); if(oldWarehouse != null) changed.add(oldWarehouse); + for(String warehouse : changed) { + SelfopSyncScope scope = held.get(warehouse); scope.setFactRevision(scope.getFactRevision()+1); + scope.setRebuildRequired(1); scope.setInvalidatedAtUtc(SelfopSupport.utcNow()); scope.setUpdatedAt(SelfopSupport.utcNow()); scopes.updateById(scope); + if(oldWarehouse != null && !oldWarehouse.equals(p.warehouseCode())) rebuild(task,scope,order); + } + } + + private void rebuild(SelfopSyncTask upstream,SelfopSyncScope scope,SelfopOutboundOrder order) { + String key = "rebuild:"+upstream.getId()+":"+order.getId()+":"+order.getRevisionNo()+":"+scope.getWarehouseCode(); + if(tasks.selectCount(new QueryWrapper().eq("source_account",scope.getSourceAccount()).eq("request_key",key)) > 0) return; + SelfopSyncTask task = new SelfopSyncTask(); task.setSourceAccount(scope.getSourceAccount()); + task.setExecutionPurpose("BUSINESS"); task.setTaskNo("SO-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); + task.setTaskRole("WAREHOUSE_SYNC"); task.setScopeId(scope.getId()); task.setWarehouseCode(scope.getWarehouseCode()); + task.setRequestKey(key); task.setRequestHash(SelfopSupport.hash(List.of(key,scope.getScopeHash()))); + task.setTaskType("REBUILD"); task.setQueryScopeJson(scope.getScopeJson()); task.setScopeHash(scope.getScopeHash()); + task.setStatus("READY"); task.setApplyState("NOT_APPLICABLE"); task.setAttemptCount(0); + task.setCreatedAt(SelfopSupport.utcNow()); task.setUpdatedAt(task.getCreatedAt()); tasks.insert(task); + } + + private SelfopPayloads.Order validateStage(SelfopSyncTask task,SelfopSyncStage stage,SelfopRoutingManifest manifest) { + SelfopSupport.require(stage != null && "VALID".equals(stage.getValidationStatus()) + && "OUTBOUND_ORDER".equals(stage.getObjectType()) && stage.getObservationSequence() != null,"DETAIL_NOT_VALID"); + SelfopSyncRun sourceRun=runs.selectById(stage.getRunId()); + SelfopCollectionPolicy.requireBusiness(sourceRun); + SelfopSyncTask sourceTask=tasks.selectById(sourceRun.getTaskId()); + SelfopCollectionPolicy.requireBusiness(sourceTask); + SelfopSupport.require(Objects.equals(task.getSourceAccount(),sourceRun.getSourceAccount()) + && Objects.equals(task.getSourceAccount(),sourceTask.getSourceAccount()),"STAGE_IDENTITY_CONFLICT"); + SelfopPayloads.Order raw = SelfopSupport.read(stage.getPayloadJson(),SelfopPayloads.Order.class); + SelfopSupport.require(Objects.equals(stage.getContentHash(),SelfopSupport.hash(raw)),"STAGE_CONTENT_CONFLICT"); + SelfopPayloads.Order p = OutboundPolicy.normalize(raw); + SelfopRoutingManifest.Entry reference = manifest.getObjects().get(stage.getObjectKeyHash()); + SelfopSupport.require(reference != null && Objects.equals(reference.getEffectiveStageId(),stage.getId()),"STAGE_NOT_ROUTED"); + if(reference.getRefreshTaskId() != null) { + SelfopSyncTask refresh = tasks.selectById(reference.getRefreshTaskId()); + SelfopSupport.require(refresh != null && "SUCCEEDED".equals(refresh.getStatus()) + && Objects.equals(stage.getStageBatchId(),reference.getRefreshTaskId()),"DETAIL_RECHECK_REQUIRED"); + } else SelfopSupport.require(Objects.equals(stage.getStageBatchId(),task.getUpstreamTaskId()),"STAGE_BATCH_CONFLICT"); + SelfopSupport.require(Objects.equals(task.getSourceAccount(),p.sourceAccount()) && Objects.equals(stage.getWarehouseCode(),p.warehouseCode()),"STAGE_IDENTITY_CONFLICT"); + SelfopSupport.require(Objects.equals(stage.getObjectKeyHash(),SelfopSupport.hash(List.of(p.sourceAccount(),p.customerCode(),p.deliveryNo()))),"STAGE_KEY_CONFLICT"); + SelfopSupport.require(stage.getObservationSequence() >= locks.newestObservation(p.sourceAccount(),stage.getObjectKeyHash()),"DETAIL_RECHECK_REQUIRED"); + return p; + } + private void mergeProgress(SelfopSyncTask task,SelfopSyncRun run,SelfopRoutingManifest manifest) { + Set required=new HashSet<>(),applied=new HashSet<>(); + boolean totalKnown=manifest.isDirectoryComplete(); + for(SelfopRoutingManifest.Entry entry:manifest.getObjects().values()) { + if(entry.getEffectiveStageId()==null)totalKnown=false; + if(entry.getEffectiveStageId()!=null)required.add(entry.getEffectiveStageId()); + if(entry.getEffectiveStageId()!=null && Objects.equals(entry.getEffectiveStageId(),entry.getAppliedStageId()))applied.add(entry.getAppliedStageId()); + } + SelfopProgressProjection.applyCheckpoint(task,run,"MERGE","ORDER",applied.size(),totalKnown?(long)required.size():null,0); + } + private String factHash(SelfopPayloads.Order p,SelfopSyncScope scope) { + SelfopSupport.require(scope != null,"LOCK_SET_CHANGED"); + return SelfopSupport.hash(List.of(p,scope.getBusinessTimezone(),scope.getSourceTimezone(),unit(scope))); + } + private String unit(SelfopSyncScope scope) { + Map json = SelfopSupport.read(scope.getScopeJson(),Map.class); Object value = json.get("quantityUnit"); + SelfopSupport.require(value instanceof String && !((String)value).isBlank(),"QUANTITY_UNIT_UNCONFIRMED"); + return (String)value; + } + private SelfopOutboundOrder findOrder(SelfopPayloads.Order p) { + return orders.selectOne(new QueryWrapper().eq("source_account",p.sourceAccount()) + .eq("customer_code",p.customerCode()).eq("delivery_no",p.deliveryNo())); + } + private SortedSet affected(SelfopSyncTask task) { + SortedSet values = new TreeSet<>(); + if(task.getAffectedWarehouseCodesJson() != null) values.addAll(Arrays.asList(SelfopSupport.read(task.getAffectedWarehouseCodesJson(),String[].class))); + return values; + } + private Map lockScopes(String account,SortedSet warehouses,Map leases) { + Map held = new LinkedHashMap<>(); + for(String warehouse : warehouses) { + SelfopSyncScope scope = locks.warehouse(account,warehouse); Lease lease = leases.get(warehouse); + SelfopSupport.require(scope != null && lease != null && Objects.equals(scope.getLeaseOwner(),lease.owner()) + && Objects.equals(scope.getFenceToken(),lease.fence()) && scope.getLeaseUntilUtc() != null + && scope.getLeaseUntilUtc().isAfter(SelfopSupport.utcNow()),"LEASE_FENCE_REJECTED"); + held.put(warehouse,scope); + } + return held; + } + private void validateTaskRun(SelfopSyncTask task,SelfopSyncRun run,Map held) { + SelfopCollectionPolicy.requireBusiness(task); SelfopCollectionPolicy.requireBusiness(run); + SelfopSyncScope own = held.get(task.getWarehouseCode()); + SelfopSupport.require(run != null && Objects.equals(run.getTaskId(),task.getId()) && Objects.equals(run.getScopeId(),task.getScopeId()) + && Objects.equals(run.getScopeHash(),own.getScopeHash()) && Objects.equals(task.getScopeHash(),own.getScopeHash()) + && Objects.equals(run.getFenceToken(),own.getFenceToken()) && !"PUBLISHED".equals(run.getStatus()),"RUN_SCOPE_FENCE_CONFLICT"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java new file mode 100644 index 000000000..6df9490fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface OutboundLockMapper { + @Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE") + SelfopSyncScope scope(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND data_family='OUTBOUND' FOR UPDATE") + SelfopSyncScope warehouse(@Param("account") String account,@Param("warehouse") String warehouse); + @Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE") + SelfopSyncTask task(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") + SelfopSyncRun run(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_stage WHERE id=#{id} FOR UPDATE") + SelfopSyncStage stage(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_outbound_order WHERE source_account=#{account} AND customer_code=#{customer} AND delivery_no=#{delivery} FOR UPDATE") + SelfopOutboundOrder order(@Param("account") String account,@Param("customer") String customer,@Param("delivery") String delivery); + @Select("SELECT COALESCE(MAX(observation_sequence),0) FROM ll_selfop_sync_stage s JOIN ll_selfop_sync_run r ON r.id=s.run_id JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE r.source_account=#{account} AND r.execution_purpose='BUSINESS' AND t.execution_purpose='BUSINESS' AND s.object_type='OUTBOUND_ORDER' AND s.object_key_hash=#{key} AND s.validation_status='VALID'") + Long newestObservation(@Param("account") String account,@Param("key") String key); + @Select("SELECT * FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") + List barriers(@Param("account") String account,@Param("warehouse") String warehouse); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java new file mode 100644 index 000000000..a823c3e4c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import java.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** Source strings are interpreted only after explicit source timezone and unit confirmation. */ +public final class OutboundPolicy { + private OutboundPolicy() {} + private static final DateTimeFormatter SOURCE = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss") + .withResolverStyle(java.time.format.ResolverStyle.STRICT); + + public static SelfopPayloads.Order normalize(SelfopPayloads.Order p) { + text(p.sourceAccount(), 64); text(p.customerCode(), 64); text(p.warehouseCode(), 64); + text(p.deliveryNo(), 128); + optional(p.sourceNo(),128); optional(p.sourceCreatedTime(),64); optional(p.sourceOutboundTime(),64); + SelfopSupport.require(p.sourceStatus() != null, "SOURCE_STATUS_MISSING"); + SelfopSupport.require(p.items() != null, "DETAIL_ITEMS_MISSING"); + SelfopSupport.require(!Integer.valueOf(100).equals(p.sourceStatus()) || !p.items().isEmpty(), "OUTBOUND_ITEMS_EMPTY"); + String quantityUnit = p.items().isEmpty() ? null : p.items().get(0).quantityUnit(); + TreeMap result = new TreeMap<>(); + for (SelfopPayloads.Item item : p.items()) { + text(item.skuCode(),128); optional(item.productName(),255); + SelfopSupport.require(Objects.equals(quantityUnit, item.quantityUnit()) && item.quantityUnit() != null && !item.quantityUnit().isBlank(), "QUANTITY_UNIT_CONFLICT"); + BigDecimal value = item.actualQuantity(); + SelfopSupport.require(value != null && value.signum() >= 0 && value.scale() <= 6 + && value.precision() - value.scale() <= 14, "INVALID_ACTUAL_QUANTITY"); + SelfopPayloads.Item old = result.get(item.skuCode()); + BigDecimal sum = old == null ? value : old.actualQuantity().add(value); + SelfopSupport.require(sum.precision() - sum.scale() <= 14, "QUANTITY_OVERFLOW"); + result.put(item.skuCode(), new SelfopPayloads.Item(item.skuCode(), + old != null && old.productName() != null ? old.productName() : item.productName(), + sum.stripTrailingZeros(), quantityUnit)); + } + return new SelfopPayloads.Order(p.sourceAccount(),p.customerCode(),p.warehouseCode(),p.deliveryNo(), + p.sourceNo(),p.sourceStatus(),p.sourceCreatedTime(),p.sourceOutboundTime(), List.copyOf(result.values())); + } + + public static LocalDateTime outboundUtc(SelfopPayloads.Order p, SelfopSyncScope scope) { + SelfopSupport.require(scope.getSourceTimezone() != null && !scope.getSourceTimezone().isBlank(), + "SOURCE_TIMEZONE_UNCONFIRMED"); + if (!Integer.valueOf(100).equals(p.sourceStatus())) return null; + text(p.sourceOutboundTime(),64); + LocalDateTime local = LocalDateTime.parse(p.sourceOutboundTime(),SOURCE); + ZoneId zone = ZoneId.of(scope.getSourceTimezone()); + List offsets = zone.getRules().getValidOffsets(local); + SelfopSupport.require(offsets.size() == 1, "SOURCE_TIME_DST_AMBIGUOUS_OR_GAP"); + return LocalDateTime.ofInstant(local.toInstant(offsets.get(0)),ZoneOffset.UTC); + } + + /** Source API includes both endpoint seconds; new facts belong to exactly one internal slice. + * Unknown source timezone retains evidence only; caller must keep formal readiness closed. + * Known facts and explicit rechecks retain revisions even when corrected outside the old slice. + */ + public static boolean belongsToSlice(SelfopPayloads.Order p, SelfopSyncScope scope, + LocalDateTime from, LocalDateTime until, + boolean knownOrder, String taskType) { + if(knownOrder || "RECHECK".equals(taskType) || !Integer.valueOf(100).equals(p.sourceStatus())) return true; + if(scope.getSourceTimezone() == null || scope.getSourceTimezone().isBlank()) return true; + if(from == null || until == null) return true; + SelfopSupport.require(from.isBefore(until),"OUTBOUND_SLICE_INVALID"); + LocalDateTime observed = outboundUtc(p,scope); + return !observed.isBefore(from) && observed.isBefore(until); + } + + public static DayCoverage completeDays(LocalDateTime from, LocalDateTime until, String timezone) { + if (from == null || until == null || !from.isBefore(until)) return new DayCoverage(null,null); + ZoneId zone = ZoneId.of(timezone); + ZonedDateTime start = from.atZone(ZoneOffset.UTC).withZoneSameInstant(zone); + ZonedDateTime end = until.atZone(ZoneOffset.UTC).withZoneSameInstant(zone); + LocalDate first = start.toLocalDate(); + if (!start.toInstant().equals(first.atStartOfDay(zone).toInstant())) first = first.plusDays(1); + LocalDate last = end.toLocalDate(); + return first.isAfter(last) ? new DayCoverage(null,null) : new DayCoverage(first,last); + } + public record DayCoverage(LocalDate from, LocalDate until) {} + private static void optional(String value,int max) { + SelfopSupport.require(value == null || value.length() <= max,"IDENTITY_TOO_LONG"); + } + private static void text(String value,int max) { + SelfopSupport.require(value != null && !value.isBlank() && value.length() <= max,"IDENTITY_OR_UNIT_MISSING"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java new file mode 100644 index 000000000..b37960c7e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import java.util.Set; + +/** Endpoint-specific inventory presentation, separate from immutable version/date identity. */ +public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold) { + public String attentionValue(){return attention==null?"ALL":attention;} + public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;} + public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";} + public void validate(String endpoint,AnalyticsQuery q){ + if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数"); + if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效"); + if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效"); + if("COVERAGE_GT_THRESHOLD".equals(attentionValue())&&coverageDaysThreshold==null)throw new BizException("超过可售天数筛选必须指定阈值"); + if("OWNERS".equals(endpoint)){ + if(keyword!=null&&(keyword.isBlank()||keyword.length()>64))throw new BizException("货主编码前缀不能为空或超长"); + if(q.customerCode()!=null||q.sku()!=null||attention!=null||sortBy!=null||coverageDaysThreshold!=null)throw new BizException("货主选项仅接受编码前缀与分页"); + }else{ + if(keyword!=null)throw new BizException("此接口不接受货主前缀"); + if("OVERVIEW".equals(endpoint)){ + AnalyticsQuery.text(q.customerCode(),64,"货主"); + if(q.sku()!=null||q.current()!=null||q.size()!=null||attention!=null||sortBy!=null)throw new BizException("货主概览不接受SKU、关注、排序或分页条件"); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java new file mode 100644 index 000000000..e1628dd88 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.util.Map; + +/** Immutable, one-scope SQL. Qualities and days aggregate independently before joining SKU keys. */ +public final class AnalyticsInventorySql { + private AnalyticsInventorySql(){} + private static String scope(AnalyticsSqlRequest p){ + return " source_account=#{p.account} AND warehouse_code=#{p.warehouse}" + +(p.customer()==null?"":" AND customer_code=#{p.customer}")+(p.sku()==null?"":" AND sku_code=#{p.sku}"); + } + private static String keys(AnalyticsSqlRequest p){return "SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+scope(p) + +" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+scope(p);} + private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} + private static String quantity(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} + private static String cte(AnalyticsSqlRequest p){ + return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," + +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," + +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," + +"CASE WHEN (s.sku_code IS NULL OR s.quantity_unit='PCS') AND (i.sku_code IS NULL OR i.quantity_unit='PCS') THEN 1 ELSE 0 END unit_valid," + +"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence," + +"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence " + +"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code " + +"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code)," + +" stock AS (SELECT customer_code,sku_code,"+quantity("physical_quantity")+" physical_quantity,"+quantity("frozen_quantity")+" frozen_quantity," + +quantity("transit_quantity")+" transit_quantity,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available_quantity," + +"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 " + +"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective_physical_quantity,MAX(observed_at_utc) observed_at_utc " + +"FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code)," + +" sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_quantity,SUM(outbound_order_count) order_count," + +"MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales " + +"WHERE sales_run_id=#{p.salesRun} AND"+scope(p)+" AND business_date>=#{p.from} AND business_date<#{p.until} GROUP BY customer_code,sku_code)," + +" last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+scope(p) + +" AND business_date<=#{p.anchor} AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)," + +" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid," + +"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc," + +stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc," + +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.complete30}=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound," + +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count,l.last_outbound " + +"FROM profiles c LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code " + +"LEFT JOIN last_sales l ON l.customer_code=c.customer_code AND l.sku_code=c.sku_code)," + +" qualified AS (SELECT v.*,CASE WHEN evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN 1 ELSE 0 END observation_eligible," + +"CASE WHEN unit_valid=1 AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN owner_outbound END outbound_quantity30d FROM values_by_sku v)," + +" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*30/outbound_quantity30d END coverage_days," + +"CASE WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d=0 THEN 1 ELSE 0 END END no_outbound," + +"CASE WHEN #{o.coverageDaysThreshold} IS NULL THEN NULL WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND available_quantity=0 THEN 0 " + +"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*30>outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold " + +"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) "; + } + private static String stockValue(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END "+field;} + private static String filter(AnalyticsInventoryOptions o){return switch(o.attentionValue()){ + case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1"; + case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1"; + case "FROZEN_STOCK" -> " WHERE frozen_quantity>0"; + case "DEFECTIVE_STOCK" -> " WHERE defective_physical_quantity>0"; + case "UNASSESSABLE" -> " WHERE unassessable=1"; + default -> "";};} + private static String order(AnalyticsInventoryOptions o){String field=switch(o.sortValue()){ + case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_30D_ASC" -> "outbound_quantity30d";default -> "available_quantity";}; + return field+" IS NULL,"+field+("OUTBOUND_30D_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";} + public static String inventoryPage(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); + return typed(cte(p)+"SELECT customer_code,sku_code,product_name,unit_valid,evidence_at_utc,physical_quantity,available_quantity,frozen_quantity,transit_quantity,defective_physical_quantity,observed_at_utc,owner_outbound,observed_outbound,order_count,last_outbound,observation_eligible,outbound_quantity30d,coverage_days,no_outbound,above_threshold,unassessable FROM result"+filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}");} + public static String inventoryCount(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); + return typed("ALL".equals(o.attentionValue())?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));} + public static String ownerTotals(Map args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count"); + for(String field:java.util.List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound")) + sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); + sql.append(",SUM(CASE WHEN physical_quantity>0 THEN 1 WHEN physical_quantity=0 THEN 0 END) physical_sku_known,SUM(observed_outbound) observed_outbound"); + for(String risk:java.util.List.of("no_outbound","above_threshold"))sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical_quantity ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown,SUM(CASE WHEN ").append(risk).append(" IS NULL THEN physical_quantity END) ").append(risk).append("_unknown_physical"); + return typed(sql+" FROM result");} + private static String ownersBase(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){return "SELECT DISTINCT customer_code FROM ("+keys(p)+") k"+(o.keyword()==null?"":" WHERE customer_code LIKE #{o.keywordPattern} ESCAPE '!'");} + public static String owners(Map args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");} + public static String ownerCount(Map args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");} + private static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java new file mode 100644 index 000000000..31d40d586 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import java.time.Instant; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +/** Canonical constructor only: this record is also Spring's HTTP query binding contract. */ +public record AnalyticsQuery(String sourceAccount, String warehouseCode, String customerCode, String sku, + String rangePreset, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate startDate, + @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, String bucket, Integer windowDays, + Integer current, Integer size, Long salesRunId, Long inventoryRunId, String salesScopeHash, + String inventoryScopeHash, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE_TIME) Instant queryAsOf) { + public int pageNumber(){return current==null?1:current;} + public int pageSize(){return size==null?20:size;} + public int rankingDays(){return windowDays==null?30:windowDays;} + public boolean pinned(){return salesRunId!=null;} + public long offset(){return (long)(pageNumber()-1)*pageSize();} + public void validate(boolean chart, boolean exactSku, boolean hot) { + text(sourceAccount,64,"来源账号"); text(warehouseCode,64,"仓库"); + if(customerCode!=null)text(customerCode,64,"货主"); if(sku!=null)text(sku,128,"SKU"); + if(exactSku){text(customerCode,64,"货主");text(sku,128,"SKU");} + if(pageNumber()<1||pageSize()<1||pageSize()>100)throw new BizException("分页范围为current≥1,size为1至100"); + if((salesRunId==null)!=(inventoryRunId==null))throw new BizException("必须携带完整版本对"); + if(pinned()){ + if(salesRunId<0||inventoryRunId<0||queryAsOf==null)throw new BizException("固定版本必须携带queryAsOf"); + if(salesRunId>0&&!hash(salesScopeHash)||inventoryRunId>0&&!hash(inventoryScopeHash))throw new BizException("固定版本缺少范围摘要"); + }else if(queryAsOf!=null||salesScopeHash!=null||inventoryScopeHash!=null||pageNumber()!=1) + throw new BizException("后续查询必须携带首屏完整上下文"); + if(chart){ + if(windowDays!=null)throw new BizException("SKU周期查询不接受windowDays"); + if(bucket!=null&&!"DAY".equals(bucket))throw new BizException("仅支持DAY日粒度"); + }else{ + if(rangePreset!=null||startDate!=null||endDate!=null||bucket!=null)throw new BizException("指标不接受图表周期参数"); + if(hot?(rankingDays()!=7&&rankingDays()!=30):(windowDays!=null))throw new BizException("热销仅支持7/30天;其他指标固定30天"); + } + } + private static boolean hash(String v){return v!=null&&v.matches("[0-9a-f]{64}");} + static void text(String v,int max,String label){if(v==null||v.isBlank()||v.length()>max)throw new BizException(label+"必填且不能超长");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java new file mode 100644 index 000000000..abceab4fd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java @@ -0,0 +1,267 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.selfoperated.analytics.statistics.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*; + +@Service +@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) +public class AnalyticsQueryService { + private static final ZoneId BUSINESS_ZONE=ZoneId.of("Asia/Shanghai"); + private final AnalyticsReadMapper mapper; + private final ObjectMapper json; + private final Clock clock; + @Value("${baiyue.self-operated-analytics.max-query-days:366}") private int maxQueryDays=366; + @Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7; + @Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json){this(mapper,json,Clock.systemUTC());} + AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock){this.mapper=mapper;this.json=json;this.clock=clock;} + private record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted, + LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){} + + public AnalyticsResponses.Page salesSummary(AnalyticsQuery q){ + Snapshot s=context(q,true,false,false); AnalyticsSqlRequest p=request(s,"SUMMARY"); + long count=mapper.count(p); + var records=mapper.page(p).stream().map(r->summary(s,r)).toList(); + return page(s,records,count,Map.of()); + } + public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){ + Snapshot s=context(q,true,true,false); + Profile sp=profile(s,s.sales),ip=profile(s,s.inventory); + if(sp==null&&ip==null)throw new BizException("所选固定版本中无该货主和SKU记录"); + String unit=sp!=null?sp.quantityUnit():ip==null?null:ip.quantityUnit(); + boolean valid="PCS".equals(unit); + List rows=s.sales==null?List.of():mapper.daily(s.sales.id(),q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(), + s.range.startDate(),s.range.endDateExclusive()); + Map daily=new HashMap<>(); + for(Daily row:rows){ + if(daily.put(row.businessDate(),row)!=null)throw new BizException("已发布版本存在重复日行"); + if(!Objects.equals(unit,row.quantityUnit()))valid=false; + } + Instant evidence=earliest(sp,ip); + List evaluated=new ArrayList<>(); List points=new ArrayList<>(); + for(LocalDate day=s.range.startDate();day.isBefore(s.range.endDateExclusive());day=day.plusDays(1)){ + Daily row=daily.get(day); + var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,s.fullFrom,s.fullUntil, + s.context.salesCoveredFrom(),s.context.salesCoveredUntil(),evidence,row==null?null:row.outboundQuantity(), + row==null?null:row.outboundOrderCount(),valid,s.sourceKnown,s.context.rebuildRequired(),"STALE".equals(s.context.salesStatus()))); + evaluated.add(point); + points.add(new AnalyticsResponses.Point(day,point.outboundQuantity(),point.observedQuantity(),point.dataStatus(), + point.reasonCodes(),unit,point.provedFrom(),point.provedUntil())); + } + var totals=SalesBucketPolicy.aggregate(evaluated); + var summary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),name(sp,ip),unit, + totals.outboundQuantity(),totals.observedQuantity(),totals.dataStatus(),totals.reasonCodes(), + totals.completeDays(),totals.partialDays(),totals.unknownDays()); + return new AnalyticsResponses.Trend(s.context,summary,List.copyOf(points)); + } + public AnalyticsResponses.Page hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);} + public AnalyticsResponses.Page slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);} + public AnalyticsResponses.Page inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){ + o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY"); + long total=mapper.inventoryCount(p,o); + return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of()); + } + public AnalyticsResponses.Page owners(AnalyticsQuery q,AnalyticsInventoryOptions o){ + o.validate("OWNERS",q);Snapshot s=context(q,false,false,false);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o); + return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,null)).toList(),total,Map.of()); + } + public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){ + o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o); + Map totals=new HashMap<>();if(raw!=null)raw.forEach((key,value)->totals.put(key.toLowerCase(Locale.ROOT),value)); + long count=number(totals,"sku_count");if(count==0)return null; + var physical=quantity(totals,"physical_quantity");var outbound=quantity(totals,"owner_outbound"); + if(outbound.overallTotal()==null)outbound=new AnalyticsResponses.QuantityTotal(null,decimal(totals,"observed_outbound"),outbound.unknownSkuCount(), + decimal(totals,"observed_outbound")==null?"UNKNOWN":"PARTIAL",List.of("SALES_WINDOW_UNAVAILABLE")); + var average=new AnalyticsResponses.QuantityTotal(divide30(outbound.overallTotal()),divide30(outbound.knownSubtotal()),outbound.unknownSkuCount(),outbound.dataStatus(),outbound.reasonCodes()); + var physicalSkus=new AnalyticsResponses.QuantityTotal(physical.unknownSkuCount()==0?decimal(totals,"physical_sku_known"):null, + decimal(totals,"physical_sku_known"),physical.unknownSkuCount(),physical.dataStatus(),physical.reasonCodes()); + return new AnalyticsResponses.OwnerOverview(s.context,q.customerCode(),null,count,physicalSkus,physical,quantity(totals,"available_quantity"), + quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average, + risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null)); + } + private AnalyticsResponses.Metric inventoryMetric(Snapshot s,Inventory r){ + boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID"; + String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT"; + var calculated=InventoryCoveragePolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted, + complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid())); + List attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_30D"); + if(Integer.valueOf(1).equals(r.aboveThreshold()))attention.add("COVERAGE_GT_THRESHOLD"); + if(positive(r.frozenQuantity()))attention.add("FROZEN_STOCK");if(positive(r.defectivePhysicalQuantity()))attention.add("DEFECTIVE_STOCK"); + if(Boolean.TRUE.equals(r.unassessable()))attention.add("UNASSESSABLE"); + List reasons=new ArrayList<>(s.context.reasonCodes());if(!observed)reasons.add("OBSERVATION_INSUFFICIENT"); + if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT"); + if(r.lastOutbound()==null)reasons.add("NO_OUTBOUND_IN_COLLECTED_HISTORY"); + if(!"VALID".equals(calculated.status()))reasons.add(calculated.status()); + Map statuses=new LinkedHashMap<>();String unknown="STALE".equals(s.context.inventoryStatus())?"STALE":"UNKNOWN"; + statuses.put("physicalQuantity",r.physicalQuantity()==null?unknown:"VALID");statuses.put("availableQuantity",r.availableQuantity()==null?unknown:"VALID"); + statuses.put("frozenQuantity",r.frozenQuantity()==null?unknown:"VALID");statuses.put("transitQuantity",r.transitQuantity()==null?unknown:"VALID"); + statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("outboundQuantity30d",r.outboundQuantity30d()==null?"UNKNOWN":"VALID"); + return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null, + r.outboundQuantity30d(),r.orderCount(),null,r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()), + divide30(r.outboundQuantity30d()),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)), + null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses)); + } + private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;} + private static LocalDate historyStart(Snapshot s){return s.context.salesCoveredFrom()==null?null:s.context.salesCoveredFrom().atZone(BUSINESS_ZONE).toLocalDate();} + private static BigDecimal divide30(BigDecimal n){return n==null?null:n.divide(BigDecimal.valueOf(30),12,java.math.RoundingMode.HALF_UP);} + private static BigDecimal decimal(Map row,String key){Object n=row.get(key);return n==null?null:n instanceof BigDecimal d?d:new BigDecimal(n.toString());} + private static long number(Map row,String key){BigDecimal n=decimal(row,key);return n==null?0:n.longValueExact();} + private static AnalyticsResponses.QuantityTotal quantity(Map row,String field){ + BigDecimal known=decimal(row,field+"_known");long unknown=number(row,field+"_unknown"); + return new AnalyticsResponses.QuantityTotal(unknown==0?known:null,known,unknown,unknown==0?"COMPLETE":known==null?"UNKNOWN":"PARTIAL", + unknown==0?List.of():List.of("METRIC_DEPENDENCY_UNKNOWN")); + } + private static AnalyticsResponses.RiskShare risk(Map row,String field,AnalyticsResponses.QuantityTotal physical,boolean unset){ + long unknown=number(row,field+"_unknown");long known=number(row,field+"_count");BigDecimal knownPhysical=decimal(row,field+"_physical"); + boolean complete=!unset&&unknown==0&&physical.overallTotal()!=null; + BigDecimal ratio=complete&&positive(physical.overallTotal())?knownPhysical.divide(physical.overallTotal(),12,java.math.RoundingMode.HALF_UP):null; + List reasons=unset?List.of("THRESHOLD_NOT_SET"):!complete?List.of("RISK_DEPENDENCY_UNKNOWN"): + !positive(physical.overallTotal())?List.of("NO_PHYSICAL_INVENTORY"):List.of(); + return new AnalyticsResponses.RiskShare(complete?known:null,complete?knownPhysical:null,ratio,unset?null:known,unset?null:knownPhysical, + unknown,decimal(row,field+"_unknown_physical"),reasons); + } + private AnalyticsResponses.Page metrics(AnalyticsQuery q,String kind,boolean hot){ + Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind); + long total=mapper.count(p); + List rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r)).toList(); + Map excluded=new TreeMap<>(); + if(!"INVENTORY".equals(kind))for(var e:mapper.excluded(p))excluded.put(e.reason(),e.total()); + return page(s,rows,total,excluded); + } + private AnalyticsResponses.SalesSummary summary(Snapshot s,Aggregate r){ + int complete=r.completeDayCount(),partial=r.partialDayCount(),unknown=r.unknownDayCount(); + String status=complete==s.range.dayCount()?"COMPLETE":r.observedQuantity()!=null?"PARTIAL":"UNKNOWN"; + List reasons=new ArrayList<>(s.context.reasonCodes()); + if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT"); + if(partial>0)reasons.add("SALES_DAY_INCOMPLETE"); if(unknown>0)reasons.add("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN"); + return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(), + r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown); + } + private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r){ + Instant evidence=utc(r.evidenceAtUtc()); + boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant()); + boolean stockValid="VALID".equals(r.inventoryQuality()); + String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":!s.trusted?"SALES_UNTRUSTED": + complete(s,s.range.startDate(),s.range.endDateExclusive())?"COMPLETE":"COVERAGE_INSUFFICIENT"; + var result=InventoryCoveragePolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.sales30Quantity(), + s.trusted,complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),observed,Boolean.TRUE.equals(r.unitValid())); + List reasons=new ArrayList<>(s.context.reasonCodes()); + if(!"COMPLETE".equals(coverage))reasons.add(coverage); + if(!"VALID".equals(result.status()))reasons.add(result.status()); + if(r.inventoryReason()!=null)reasons.add(r.inventoryReason()); + BigDecimal today=r.todayQuantity(); + if(today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){ + Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant(); + Instant lower=evidence.isAfter(start)?evidence:start; + if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom(); + if(lower.isBefore(s.context.salesCoveredUntil()))today=BigDecimal.ZERO; + } + return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(), + r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null, + stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(), + r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of()); + } + private Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){ + q.validate(chart,exact,hot); Instant now=clock.instant(); + List scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode()); + Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY"); + if(salesScope==null&&stockScope==null)throw new BizException("来源账号或仓库未配置"); + Run sales=resolve(salesScope,q.pinned()?q.salesRunId():null,q.salesScopeHash()); + Run inventory=resolve(stockScope,q.pinned()?q.inventoryRunId():null,q.inventoryScopeHash()); + JsonNode sc=config(sales),ic=config(inventory); + String zone=sales!=null?sc.path("businessTimezone").asText():inventory!=null?ic.path("businessTimezone").asText(): + salesScope!=null?salesScope.businessTimezone():stockScope.businessTimezone(); + if(!BUSINESS_ZONE.getId().equals(zone)||inventory!=null&&!BUSINESS_ZONE.getId().equals(ic.path("businessTimezone").asText())) + throw new BizException("发布版本不符合已确认的北京时间口径"); + Instant asOf=q.pinned()?q.queryAsOf():now; + if(asOf.isAfter(now))throw new BizException("queryAsOf不能晚于服务端时间"); + if(sales!=null&&(sales.publishedAtUtc()==null||asOf.isBefore(utc(sales.publishedAtUtc()))) + ||inventory!=null&&(inventory.publishedAtUtc()==null||asOf.isBefore(utc(inventory.publishedAtUtc())))) + throw new BizException("queryAsOf不能早于所选版本发布时间"); + Instant expires=asOf.plusSeconds(1800); + if(sales!=null&&sales.supersededAtUtc()!=null){ + Instant deadline=utc(sales.supersededAtUtc()).plus(Duration.ofDays(Math.max(7,retentionDays))); + if(deadline.isBefore(expires))expires=deadline; + } + if(!now.isBefore(expires))throw new BizException("查询上下文或统计版本已过期,请刷新整页"); + LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate(); + SalesRangePolicy.Range range; + try{ + range=chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), + q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays): + new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30, + anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant()); + }catch(IllegalArgumentException e){throw new BizException("查询周期或日期范围无效,最多"+maxQueryDays+"个自然日");} + boolean rebuild=salesScope!=null&&(Boolean.TRUE.equals(salesScope.rebuildRequired()) + ||!Objects.equals(salesScope.factRevision(),salesScope.publishedFactRevision())||mapper.applyBarriers(q.sourceAccount(),q.warehouseCode())>0); + boolean known=sales!=null&&BUSINESS_ZONE.getId().equals(sc.path("sourceTimezone").asText()); + String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(sales.coveredUntilUtc(),threshold(sc))?"STALE":"VALID"; + if(rebuild)salesState="REBUILD_REQUIRED"; + String inventoryState=inventory==null?"UNKNOWN":stale(inventory.fetchFinishedAtUtc(),threshold(ic))?"STALE":"VALID"; + LocalDate fullFrom=sales==null?null:sales.completeDayFrom(),fullUntil=sales==null?null:sales.completeDayUntil(); + if(sales!=null&&(sales.coveredFromUtc()==null||sales.coveredUntilUtc()==null||!sales.coveredFromUtc().isBefore(sales.coveredUntilUtc()))){ + fullFrom=null;fullUntil=null; + } + if(sales!=null&&sales.coveredFromUtc()!=null&&fullFrom!=null){ + var covered=utc(sales.coveredFromUtc()).atZone(BUSINESS_ZONE); LocalDate first=covered.toLocalDate(); + if(!covered.toLocalTime().equals(LocalTime.MIDNIGHT))first=first.plusDays(1); + if(first.isAfter(fullFrom))fullFrom=first; + } + if(sales!=null&&sales.coveredUntilUtc()!=null&&fullUntil!=null){ + LocalDate last=utc(sales.coveredUntilUtc()).atZone(BUSINESS_ZONE).toLocalDate(); + if(last.isBefore(fullUntil))fullUntil=last; if(anchor.isBefore(fullUntil))fullUntil=anchor; + } + String review=reviewStatus(salesScope); List reasons=new ArrayList<>(); + if(!"VALID".equals(salesState))reasons.add("SALES_"+salesState); + if(!"VALID".equals(inventoryState))reasons.add("INVENTORY_"+inventoryState); + if(!"CURRENT".equals(review))reasons.add("REVISION_REVIEW_"+review); + var c=new AnalyticsResponses.Context(sales==null?0L:sales.id(),inventory==null?0L:inventory.id(),sales==null?null:sales.scopeHash(), + inventory==null?null:inventory.scopeHash(),asOf,expires,zone,anchor,range.startDate(),range.endDateExclusive(),chart?null:range.dayCount(), + fullFrom,fullUntil,sales==null?null:utc(sales.coveredFromUtc()),sales==null?null:utc(sales.coveredUntilUtc()), + sales==null?null:utc(sales.publishedAtUtc()),inventory==null?null:utc(inventory.fetchStartedAtUtc()), + inventory==null?null:utc(inventory.fetchFinishedAtUtc()),salesState,inventoryState,rebuild,review,List.copyOf(reasons)); + return new Snapshot(q,sales,inventory,known,"VALID".equals(salesState),fullFrom,fullUntil,range,c); + } + private AnalyticsSqlRequest request(Snapshot s,String kind){ + int size=s.q.pageSize(); if("HOT".equals(kind)||"SLOW".equals(kind))size=(int)Math.max(0,Math.min(size,100-s.q.offset())); + return new AnalyticsSqlRequest(s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku(),s.context.salesRunId(), + s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()), + s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown, + s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()), + s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind); + } + private Run resolve(Scope scope,Long requested,String hash){ + if(scope==null){if(requested!=null&&requested!=0)throw new BizException("版本任务族未配置");return null;} + Long id=requested==null?scope.publishedRunId():requested; if(id==null||id==0)return null; + Run run=mapper.publishedRun(id); + if(run==null||!Objects.equals(run.scopeId(),scope.id())||!scope.sourceAccount().equals(run.sourceAccount()) + ||!scope.warehouseCode().equals(run.warehouseCode())||!scope.scopeHash().equals(run.scopeHash()) + ||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status())) + throw new BizException("版本不可用或范围不符,请刷新整页"); + if(!"COMPLETE".equals(run.coverageStatus()))throw new BizException("发布版本缺少完整覆盖证明"); + return run; + } + private Profile profile(Snapshot s,Run run){return run==null?null:mapper.profile(run.id(),s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku());} + private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&s.sales!=null&&s.sales.coveredFromUtc()!=null&&s.sales.coveredUntilUtc()!=null + &&s.sales.coveredFromUtc().isBefore(s.sales.coveredUntilUtc())&&s.fullFrom!=null&&s.fullUntil!=null&&!from.isBefore(s.fullFrom)&&!until.isAfter(s.fullUntil);} + private JsonNode config(Run r){if(r==null)return json.createObjectNode();try{JsonNode n=json.readTree(r.scopeSnapshotJson());if(n==null||!n.isObject())throw new IllegalArgumentException();return n;}catch(Exception e){throw new BizException("版本配置快照无效");}} + private static int threshold(JsonNode c){int n=c.path("staleAfterSeconds").asInt();if(n<=0)throw new BizException("版本过期阈值无效");return n;} + private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));} + private String reviewStatus(Scope s){if(s==null||s.knownOrderRoundFinishedAtUtc()==null||s.historyRoundFinishedAtUtc()==null||s.reviewTargetDays()==null||s.reviewTargetDays()<=0)return "UNKNOWN";int age=Math.multiplyExact(s.reviewTargetDays(),86400);return stale(s.knownOrderRoundFinishedAtUtc(),age)||stale(s.historyRoundFinishedAtUtc(),age)?"LAGGING":"CURRENT";} + private static Scope scope(List ss,String family){return ss.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null);} + private static String name(Profile s,Profile i){return i!=null&&i.productName()!=null?i.productName():s==null?null:s.productName();} + private static Instant earliest(Profile s,Profile i){Instant a=evidence(s,"VALID_OUTBOUND"),b=evidence(i,"INVENTORY_OBSERVATION");return a==null?b:b==null||a.isBefore(b)?a:b;} + private static Instant evidence(Profile p,String type){return p!=null&&type.equals(p.evidenceType())&&p.evidenceRunId()!=null?utc(p.earliestEvidenceAtUtc()):null;} + private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);} + private static AnalyticsResponses.Page page(Snapshot s,List rows,long total,Map exclusions){int size=s.q.pageSize();return new AnalyticsResponses.Page<>(s.context,rows,s.q.pageNumber(),size,total,total/size+(total%size==0?0:1),Map.copyOf(exclusions));} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java new file mode 100644 index 000000000..b96b79445 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.util.List; +import org.apache.ibatis.annotations.*; + +/** Reads immutable published versions in Lastleg only. */ +@Mapper +public interface AnalyticsReadMapper { + String RUN_COLUMNS="id,task_id,scope_id,source_account,warehouse_code,run_role,scope_hash,scope_snapshot_json,status," + +"coverage_status,covered_from_utc,covered_until_utc,complete_day_from,complete_day_until," + +"fetch_started_at_utc,fetch_finished_at_utc,published_at_utc,calculated_fact_revision,error_code,error_message,superseded_at_utc,execution_purpose"; + @Select("SELECT id,source_account,warehouse_code,data_family,scope_hash,business_timezone,source_timezone," + +"stale_after_seconds,published_run_id,fact_revision,published_fact_revision,rebuild_required," + +"known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days " + +"FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} ORDER BY data_family") + List scopes(@Param("account") String account,@Param("warehouse") String warehouse); + @Select("SELECT "+RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE id=#{id} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS'") + AnalyticsReadRows.Run publishedRun(@Param("id") long id); + @SelectProvider(type=AnalyticsReadSql.class,method="page") + List page(@Param("p") AnalyticsSqlRequest request); + @SelectProvider(type=AnalyticsReadSql.class,method="count") + long count(@Param("p") AnalyticsSqlRequest request); + record Exclusion(String reason,Long total){} + @SelectProvider(type=AnalyticsReadSql.class,method="excluded") + List excluded(@Param("p") AnalyticsSqlRequest request); + @Select("SELECT customer_code,sku_code,product_name,quantity_unit,first_seen_at_utc,earliest_evidence_at_utc," + +"evidence_type,evidence_run_id,last_seen_run_id FROM ll_selfop_sku_profile WHERE profile_run_id=#{run} " + +"AND source_account=#{account} AND warehouse_code=#{warehouse} AND customer_code=#{customer} AND sku_code=#{sku}") + AnalyticsReadRows.Profile profile(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku); + @Select("SELECT customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit " + +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{run} AND source_account=#{account} AND warehouse_code=#{warehouse} " + +"AND customer_code=#{customer} AND sku_code=#{sku} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date") + List daily(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku,@Param("from") java.time.LocalDate from,@Param("until") java.time.LocalDate until); + @Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') " + +"AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") + long applyBarriers(@Param("account") String account,@Param("warehouse") String warehouse); + @SelectProvider(type=AnalyticsInventorySql.class,method="inventoryPage") + List inventoryPage(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="inventoryCount") + long inventoryCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="owners") + List owners(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="ownerCount") + long ownerCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="ownerTotals") + java.util.Map ownerTotals(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java new file mode 100644 index 000000000..ae5df03e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** Read projections only; DATETIME columns are UTC, never JVM-local time. */ +public final class AnalyticsReadRows { + private AnalyticsReadRows() {} + public record Scope(Long id, String sourceAccount, String warehouseCode, String dataFamily, + String scopeHash, String businessTimezone, String sourceTimezone, + Integer staleAfterSeconds, Long publishedRunId, Long factRevision, + Long publishedFactRevision, Boolean rebuildRequired, + LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc, + Integer reviewTargetDays) {} + public record Run(Long id, Long taskId, Long scopeId, String sourceAccount, String warehouseCode, String runRole, + String scopeHash, String scopeSnapshotJson, String status, String coverageStatus, + LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc, + LocalDate completeDayFrom, LocalDate completeDayUntil, + LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc, + LocalDateTime publishedAtUtc, Long calculatedFactRevision, + String errorCode, String errorMessage, LocalDateTime supersededAtUtc, String executionPurpose) {} + public record Profile(String customerCode, String skuCode, String productName, String quantityUnit, + LocalDateTime firstSeenAtUtc, LocalDateTime earliestEvidenceAtUtc, + String evidenceType, Long evidenceRunId, Long lastSeenRunId) {} + public record Daily(String customerCode, String skuCode, LocalDate businessDate, + BigDecimal outboundQuantity, Long outboundOrderCount, String quantityUnit) {} + public record Aggregate(String customerCode, String skuCode, String productName, String quantityUnit, + LocalDateTime evidenceAtUtc, Boolean unitValid, BigDecimal observedQuantity, Long skuOrderCount, + Integer completeDayCount, Integer partialDayCount, Integer unknownDayCount, + BigDecimal windowQuantity, Long windowSkuOrderCount, BigDecimal sales30Quantity, BigDecimal todayQuantity, + BigDecimal physicalQuantity, BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity, + LocalDateTime inventoryObservedAtUtc, String inventoryQuality, String inventoryReason) {} + public record Inventory(String customerCode,String skuCode,String productName,Boolean unitValid, + LocalDateTime evidenceAtUtc,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity, + BigDecimal transitQuantity,BigDecimal defectivePhysicalQuantity,LocalDateTime observedAtUtc,BigDecimal ownerOutbound, + BigDecimal observedOutbound,Long orderCount,LocalDate lastOutbound,Boolean observationEligible, + BigDecimal outboundQuantity30d,BigDecimal coverageDays,Integer noOutbound,Integer aboveThreshold,Boolean unassessable){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java new file mode 100644 index 000000000..beefeefef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java @@ -0,0 +1,147 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +/** MySQL 8 CTEs: bounded by one version, warehouse and date range; no order/item/stage reads. */ +public final class AnalyticsReadSql { + private AnalyticsReadSql(){} + private static final String KEY_FILTER=" source_account=#{p.account} AND warehouse_code=#{p.warehouse}" + +" AND (#{p.customer} IS NULL OR customer_code=#{p.customer}) AND (#{p.sku} IS NULL OR sku_code=#{p.sku}) "; + private static String candidates(boolean salesOnly){ + String sql="WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND" + +KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+")," + +" dim AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," + +"COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit," + +"CASE WHEN (s.sku_code IS NOT NULL AND s.quantity_unit IS NULL) OR (i.sku_code IS NOT NULL AND i.quantity_unit IS NULL) THEN 0 " + +"WHEN s.quantity_unit IS NOT NULL AND i.quantity_unit IS NOT NULL AND s.quantity_unit<>i.quantity_unit THEN 0 " + +"WHEN COALESCE(s.quantity_unit,i.quantity_unit)<>'PCS' OR COALESCE(s.quantity_unit,i.quantity_unit) IS NULL THEN 0 ELSE 1 END profile_unit_valid," + +"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence," + +"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence " + +"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} " + +"AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code " + +"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} " + +"AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code)," + +" evidence AS (SELECT dim.*,CASE WHEN sales_evidence IS NULL THEN stock_evidence WHEN stock_evidence IS NULL THEN sales_evidence " + +"ELSE LEAST(sales_evidence,stock_evidence) END evidence_at_utc FROM dim),"; + if(salesOnly){ + int start=sql.indexOf("CASE WHEN (s.sku_code IS NOT NULL"),end=sql.indexOf("profile_unit_valid,",start); + sql=sql.substring(0,start)+"CASE WHEN s.sku_code IS NOT NULL THEN CASE WHEN s.quantity_unit='PCS' THEN 1 ELSE 0 END WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END "+sql.substring(end); + sql=sql.replace("COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit","CASE WHEN s.sku_code IS NOT NULL THEN s.quantity_unit ELSE i.quantity_unit END quantity_unit"); + } + return sql; + } + private static String cte(String kind){ + String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; + String observedStart="CONVERT_TZ(GREATEST(evidence_at_utc,#{p.coveredFrom}),'+00:00','+08:00')"; + String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')"; + String sql=candidates("SUMMARY".equals(kind))+" bounds AS (SELECT evidence.*," + +"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.completeFrom} IS NOT NULL AND #{p.completeUntil} IS NOT NULL THEN " + +"GREATEST(#{p.from},#{p.completeFrom},TIMESTAMPADD(DAY,CASE WHEN CAST("+localEvidence+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+localEvidence+"))) END full_start," + +"LEAST(#{p.until},#{p.completeUntil},#{p.anchor}) full_end," + +"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN GREATEST(#{p.from},DATE("+observedStart+")) END observed_start," + +"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN LEAST(#{p.until},TIMESTAMPADD(DAY,CASE WHEN CAST("+observedEnd+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+observedEnd+"))) END observed_end " + +"FROM evidence)," + +" ranged AS (SELECT b.customer_code,b.sku_code,SUM(d.outbound_quantity) observed_sum,COUNT(d.id) actual_days," + +"SUM(d.outbound_order_count) order_sum,MIN(d.quantity_unit) min_unit,MAX(d.quantity_unit) max_unit," + +"SUM(CASE WHEN d.business_date>=#{p.completeFrom} AND d.business_date<#{p.completeUntil} AND d.business_date<#{p.anchor} THEN 1 ELSE 0 END) actual_complete," + +"SUM(CASE WHEN d.business_date>=b.full_start AND d.business_date=b.observed_start AND d.business_date=#{p.from} AND d.business_date<#{p.until} GROUP BY b.customer_code,b.sku_code)," + +" metric_sales AS (SELECT customer_code,sku_code," + +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity," + +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders," + +"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," + +"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity ELSE NULL END) today_quantity," + +"MIN(quantity_unit) min_unit,MAX(quantity_unit) max_unit " + +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER + +" AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date0 THEN COALESCE(c.observed_sum,0) END observed_quantity," + +"c.order_sum sku_order_count,CASE WHEN c.unit_valid=1 THEN c.complete_count ELSE 0 END complete_day_count," + +"CASE WHEN c.unit_valid=1 THEN GREATEST(0,c.observed_count-c.complete_count) ELSE 0 END partial_day_count," + +"TIMESTAMPDIFF(DAY,#{p.from},#{p.until})-CASE WHEN c.unit_valid=1 THEN c.observed_count ELSE 0 END unknown_day_count," + +"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_quantity,0) END window_quantity," + +"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_orders,0) END window_sku_order_count," + +"CASE WHEN c.unit_valid=1 AND #{p.complete30}=1 THEN COALESCE(c.sales30_quantity,0) END sales30_quantity," + +"CASE WHEN c.unit_valid=1 AND #{p.salesKnown}=1 THEN c.today_quantity END today_quantity," + +"st.physical_quantity,st.available_quantity,st.frozen_quantity,st.transit_quantity,st.observed_at_utc inventory_observed_at_utc," + +"CASE WHEN st.sku_code IS NULL THEN 'UNKNOWN' WHEN st.quality_status='INVALID' THEN 'INVALID' " + +"WHEN st.quality_status<>'VALID' OR st.available_quantity IS NULL OR st.physical_quantity IS NULL OR st.frozen_quantity IS NULL OR st.transit_quantity IS NULL THEN 'UNKNOWN' " + +"WHEN st.available_quantity<0 OR st.physical_quantity<0 OR st.frozen_quantity<0 OR st.transit_quantity<0 THEN 'INVALID' " + +"WHEN #{p.stockFresh}=0 THEN 'STALE' ELSE 'VALID' END inventory_quality,st.quality_reason inventory_reason " + +"FROM counted c LEFT JOIN ll_selfop_inventory_snapshot st ON st.snapshot_run_id=#{p.inventoryRun} AND st.source_account=#{p.account} " + +"AND st.warehouse_code=#{p.warehouse} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code AND st.product_quality=0) "; + if("SUMMARY".equals(kind)){ + int start=sql.indexOf(" metric_sales AS (");int end=sql.indexOf(" counted AS (",start); + sql=sql.substring(0,start)+" metric_sales AS (SELECT customer_code,sku_code,CAST(NULL AS DECIMAL(20,6)) window_quantity," + +"CAST(NULL AS DECIMAL(20,0)) window_orders,CAST(NULL AS DECIMAL(20,6)) sales30_quantity," + +"CAST(NULL AS DECIMAL(20,6)) today_quantity,CAST(NULL AS CHAR(32)) min_unit,CAST(NULL AS CHAR(32)) max_unit FROM bounds WHERE 1=0),"+sql.substring(end); + // A sales chart does not require inventory quantities or freshness. Keep only profile evidence. + int stockJoin=sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot st"); + sql=sql.substring(0,stockJoin)+"LEFT JOIN (SELECT CAST(NULL AS DECIMAL(20,0)) snapshot_run_id,CAST(NULL AS CHAR(64)) source_account," + +"CAST(NULL AS CHAR(64)) warehouse_code,CAST(NULL AS CHAR(64)) customer_code,CAST(NULL AS CHAR(128)) sku_code," + +"CAST(NULL AS DECIMAL(20,6)) physical_quantity,CAST(NULL AS DECIMAL(20,6)) available_quantity," + +"CAST(NULL AS DECIMAL(20,6)) frozen_quantity,CAST(NULL AS DECIMAL(20,6)) transit_quantity,CAST(NULL AS DATETIME) observed_at_utc," + +"CAST(NULL AS CHAR(16)) quality_status,CAST(NULL AS CHAR(64)) quality_reason,CAST(NULL AS DECIMAL(6,0)) product_quality) st ON 1=0) "; + } + return sql; + } + private static String filter(String kind){ + if("HOT".equals(kind))return " WHERE window_quantity>0 "; + if("SLOW".equals(kind))return " WHERE window_quantity IS NOT NULL AND inventory_quality='VALID' AND available_quantity>0 " + +"AND evidence_at_utc<=CONVERT_TZ(CAST(TIMESTAMPADD(DAY,-30,#{p.anchor}) AS DATETIME),'+08:00','+00:00') "; + return ""; + } + private static String order(String kind){ + if("HOT".equals(kind))return "window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC"; + if("SLOW".equals(kind))return "window_quantity ASC,available_quantity DESC,sku_code ASC,customer_code ASC"; + if("SUMMARY".equals(kind))return "observed_quantity IS NULL,observed_quantity DESC,sku_code ASC,customer_code ASC"; + return "sku_code ASC,customer_code ASC"; + } + public static String page(java.util.Map parameters){ + var p=(AnalyticsSqlRequest)parameters.get("p"); + return typed(cte(p.kind())+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count," + +"complete_day_count,partial_day_count,unknown_day_count,window_quantity,window_sku_order_count,sales30_quantity,today_quantity," + +"physical_quantity,available_quantity,frozen_quantity,transit_quantity,inventory_observed_at_utc,inventory_quality,inventory_reason " + +"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind())+" LIMIT #{p.size} OFFSET #{p.offset}",p); + } + public static String count(java.util.Map parameters){ + var p=(AnalyticsSqlRequest)parameters.get("p"); + // Candidate totals do not scan daily rows for normal lists. + if(!"HOT".equals(p.kind())&&!"SLOW".equals(p.kind()))return typed("SELECT COUNT(*) FROM (SELECT customer_code,sku_code FROM ll_selfop_sku_profile " + +"WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile " + +"WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+") candidate_keys",p); + return typed(cte(p.kind())+"SELECT LEAST(COUNT(*),100) FROM result"+filter(p.kind()),p); + } + public static String excluded(java.util.Map parameters){ + var p=(AnalyticsSqlRequest)parameters.get("p"); + String reason="HOT".equals(p.kind())?"CASE WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'ZERO_OUTBOUND' END": + "CASE WHEN inventory_quality<>'VALID' THEN CONCAT('INVENTORY_',inventory_quality) WHEN available_quantity<=0 THEN 'NO_AVAILABLE_INVENTORY' " + +"WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'OBSERVATION_INSUFFICIENT' END"; + return typed(cte(p.kind())+"SELECT "+reason+" reason,COUNT(*) total FROM result WHERE NOT ("+filter(p.kind()).replace(" WHERE ","").strip()+") OR window_quantity IS NULL" + +("SLOW".equals(p.kind())?" OR evidence_at_utc IS NULL":"")+" GROUP BY reason",p); + } + private static String typed(String sql,AnalyticsSqlRequest p){ + String filters=" source_account=#{p.account} AND warehouse_code=#{p.warehouse} "; + if(p.customer()!=null)filters+="AND customer_code=#{p.customer} "; + if(p.sku()!=null)filters+="AND sku_code=#{p.sku} "; + sql=sql.replace(KEY_FILTER,filters); + // Explicit expression types preserve NULL semantics and avoid inference from bare JDBC parameters. + for(String key:java.util.List.of("from","until","anchor","windowFrom","completeFrom","completeUntil")) + sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)"); + for(String key:java.util.List.of("coveredFrom","coveredUntil")) + sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATETIME)"); + return sql; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java new file mode 100644 index 000000000..2b6c872d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public final class AnalyticsResponses { + private AnalyticsResponses(){} + public record Context(Long salesRunId,Long inventoryRunId,String salesScopeHash,String inventoryScopeHash, + Instant queryAsOf,Instant queryExpiresAt,String businessTimezone,LocalDate anchorDate, + LocalDate rangeStartDate,LocalDate rangeEndDateExclusive,Integer calculationWindowDays, + LocalDate completeDayFrom,LocalDate completeDayUntil,Instant salesCoveredFrom,Instant salesCoveredUntil, + Instant salesPublishedAt,Instant inventoryObservedFrom,Instant inventoryObservedUntil, + String salesStatus,String inventoryStatus,boolean rebuildRequired,String revisionReviewStatus,List reasonCodes){} + public record Page(Context context,List records,int current,int size,long total,long pages,Map excludedReasons){} + public record SalesSummary(String sourceAccount,String warehouseCode,String customerCode,String sku,String productName, + String quantityUnit,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,List reasonCodes, + int completeDayCount,int partialDayCount,int unknownDayCount){} + public record Point(LocalDate businessDate,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus, + List reasonCodes,String quantityUnit,Instant coveredFrom,Instant coveredUntil){} + public record Trend(Context context,SalesSummary summary,List points){} + public record Metric(String sourceAccount,String customerCode,String warehouseCode,String sku,String productName, + String quantityUnit,BigDecimal outboundQuantity,Long skuOutboundOrderCount,BigDecimal todayObservedQuantity, + BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,BigDecimal transitQuantity, + Instant inventoryObservedAt,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays, + String inventoryQuality,String salesCoverage,boolean observationEligible,Instant earliestReliableEvidenceAt, + String status,List reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity, + BigDecimal outboundQuantity30d,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate, + List attentionReasons,Map quantityStatuses){} + public record OwnerOption(String customerCode,String customerName){} + public record QuantityTotal(BigDecimal overallTotal,BigDecimal knownSubtotal,long unknownSkuCount,String dataStatus,List reasonCodes){} + public record RiskShare(Long matchedSkuCount,BigDecimal matchedPhysicalQuantity,BigDecimal physicalQuantityRatio, + Long knownMatchedSkuCount,BigDecimal knownMatchedPhysicalQuantity,long unassessableSkuCount, + BigDecimal unassessableKnownPhysicalQuantity,List reasonCodes){} + public record OwnerOverview(Context context,String customerCode,String customerName,Long skuCount,QuantityTotal physicalSkuCount, + QuantityTotal physicalQuantity,QuantityTotal availableQuantity,QuantityTotal frozenQuantity,QuantityTotal defectivePhysicalQuantity, + QuantityTotal transitQuantity,QuantityTotal outboundQuantity30d,QuantityTotal averageDailyOutboundQuantity, + RiskShare noOutbound30d,RiskShare coverageAboveThreshold){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java new file mode 100644 index 000000000..a9d8bfe78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** Bound parameters only, generated by the validated service context. */ +public record AnalyticsSqlRequest(String account,String warehouse,String customer,String sku,long salesRun,long inventoryRun, + LocalDate from,LocalDate until,LocalDate anchor,LocalDate windowFrom,LocalDate completeFrom,LocalDate completeUntil, + LocalDateTime coveredFrom,LocalDateTime coveredUntil,boolean salesKnown,boolean formalSales,boolean completeWindow, + boolean complete30,boolean stockFresh,long offset,int size,String kind) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java new file mode 100644 index 000000000..fc2e328fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDate; +import java.time.Instant; +import org.springframework.format.annotation.DateTimeFormat; + +/** Single canonical constructor supports Spring ModelAttribute binding. */ +public record CollectionQuery(String sourceAccount, String warehouseCode, String dataFamily, + String taskType, String status, String taskNo, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateUntil, + Integer current, Integer size, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant listUpperCreatedAt, + Long listUpperId) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java new file mode 100644 index 000000000..600117510 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java @@ -0,0 +1,350 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*; + +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class CollectionQueryService { + private final CollectionReadMapper mapper; + private final SelfopAnalyticsProperties config; + private final ObjectMapper json; + private final Clock clock; + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final Set TERMINAL = Set.of("ROUTED","PUBLISHED","REUSED","YIELDED","FAILED","ABORTED","SAMPLE_DONE"); + private static final Set PROGRESS_FIELDS = Set.of("progressScope","countBasis","attemptNo","scanRound","phase", + "completed","total","totalKnown","unit","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt", + "validationState","invalidCount","unknownCount","pendingReviewCount","countUnit","summaryAsOf","directoryComplete"); + private static final Set COUNTS = Set.of("completed","total","invalidCount","unknownCount","pendingReviewCount","attemptNo","scanRound"); + private static final Set UNITS = Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","SKU","BATCH","NONE"); + @Autowired + public CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json) { + this(mapper, config, json, Clock.systemUTC()); + } + CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json, Clock clock) { + this.mapper=mapper; this.config=config; this.json=json; this.clock=clock; + } + private record Context(Map tasks,Map latest,Map published) {} + private Context context(String account,List base,List extraPublished) { + Map tasks=new HashMap<>();for(Task t:base)tasks.put(t.id(),t); + Set related=new HashSet<>(); + for(Task t:base){if(t.validationRootTaskId()!=null)related.add(t.validationRootTaskId());if(t.upstreamTaskId()!=null)related.add(t.upstreamTaskId());} + related.removeAll(tasks.keySet()); + if(!related.isEmpty())for(Task t:mapper.tasksByIds(account,new ArrayList<>(related)))tasks.put(t.id(),t); + Map latest=new HashMap<>(); + if(!tasks.isEmpty())for(Attempt a:mapper.latestAttempts(account,new ArrayList<>(tasks.keySet())))latest.put(a.taskId(),a); + Set versions=new HashSet<>(extraPublished);versions.remove(null); + for(Task t:tasks.values())if(t.resultPublishedRunId()!=null)versions.add(t.resultPublishedRunId()); + Map published=new HashMap<>(); + if(!versions.isEmpty())for(Attempt a:mapper.publishedRuns(account,new ArrayList<>(versions)))published.put(a.id(),a); + return new Context(tasks,latest,published); + } + + public CollectionResponses.Overview overview(CollectionQuery query) { + validate(query, false); + List scopes=mapper.scopes(query.sourceAccount(),query.warehouseCode()); + Map persisted=new HashMap<>(); + for(Scope scope:scopes) persisted.put(scope.warehouseCode()+":"+scope.dataFamily(),scope); + var rows=new ArrayList(); + var protection=protection(query.sourceAccount()); + Map inventorySamples=inventorySamples(query); + List scopeIds=scopes.stream().filter(s->config.getWarehouses().containsKey(s.warehouseCode())).map(Scope::id).toList(); + List currentTasks=scopeIds.isEmpty()?List.of():mapper.currentTasks(query.sourceAccount(),scopeIds); + List successes=scopeIds.isEmpty()?List.of():mapper.lastSuccessfulTasks(query.sourceAccount(),scopeIds); + Task active=mapper.sharedActiveAccount(query.sourceAccount()); + List all=new ArrayList<>(currentTasks);all.addAll(successes);if(active!=null)all.add(active); + Context context=context(query.sourceAccount(),all,scopes.stream().map(Scope::publishedRunId).filter(Objects::nonNull).toList()); + Set barriers=config.getWarehouses().isEmpty()?Set.of():new HashSet<>(mapper.barrierWarehouses(query.sourceAccount(),new ArrayList<>(config.getWarehouses().keySet()))); + for(String warehouse:new TreeSet<>(config.getWarehouses().keySet())) { + if(query.warehouseCode()!=null&&!warehouse.equals(query.warehouseCode())) continue; + for(String family:List.of("OUTBOUND","INVENTORY")) { + if(query.dataFamily()!=null&&!family.equals(query.dataFamily()))continue; + Scope scope=persisted.get(warehouse+":"+family); + Task current=currentTasks.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null); + Task last=successes.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null); + Attempt published=scope==null?null:context.published().get(scope.publishedRunId()); + if(published!=null&&(!warehouse.equals(published.warehouseCode())||!Objects.equals(scope.id(),published.scopeId()) + ||!Objects.equals(scope.scopeHash(),published.scopeHash())))published=null; + Task shared="OUTBOUND".equals(family)&&sharedWindowRelated(active,scope)?active:null; + String blocked=executionBlock(warehouse,protection); + boolean enabled=blocked==null; + boolean publication="BUSINESS".equals(config.getCollectionMode())&&"production".equals(config.getEnvironment()) + &&("INVENTORY".equals(family)||config.isSalesPublicationEnabled()); + String initial="INVENTORY".equals(family)?published!=null&&"COMPLETE".equals(published.coverageStatus())?"COMPLETE":"UNKNOWN" + :scope==null||scope.initialFromUtc()==null||scope.initialUntilUtc()==null?"UNKNOWN" + :published!=null&&"COMPLETE".equals(published.coverageStatus())&&published.coveredFromUtc()!=null&&published.coveredUntilUtc()!=null + &&!published.coveredFromUtc().isAfter(scope.initialFromUtc())&&!published.coveredUntilUtc().isBefore(scope.initialUntilUtc()) + &&!Integer.valueOf(1).equals(scope.rebuildRequired())&&!barriers.contains(warehouse)?"COMPLETE":"PARTIAL"; + Instant observation=published==null?null:utc("INVENTORY".equals(family)?published.fetchFinishedAtUtc():published.coveredUntilUtc()); + String freshness=published==null?"NOT_COLLECTED":scope.staleAfterSeconds()==null||scope.staleAfterSeconds()<=0?"UNKNOWN" + :observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH"; + rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked, + enabled?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()), + scope==null?null:utc(scope.initialUntilUtc()),initial,published==null?null:utc(published.coveredFromUtc()), + published==null?null:utc(published.coveredUntilUtc()),last==null?null:last.taskNo(),last==null||context.latest().get(last.id())==null?null:utc(context.latest().get(last.id()).finishedAtUtc()), + published==null?null:utc(published.publishedAtUtc()),"INVENTORY".equals(family)&&published!=null?utc(published.fetchStartedAtUtc()):null, + "INVENTORY".equals(family)&&published!=null?utc(published.fetchFinishedAtUtc()):null,freshness, + "OUTBOUND".equals(family)&&scope!=null&&(Integer.valueOf(1).equals(scope.rebuildRequired())||barriers.contains(warehouse)),reviewStatus(scope), + current==null?null:taskView(current,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection, + "INVENTORY".equals(family)?inventorySamples.get(warehouse):null)); + } + } + return new CollectionResponses.Overview(query.sourceAccount(),config.getEnvironment(),config.getCollectionMode(),clock.instant(),rows); + } + + private Map inventorySamples(CollectionQuery query) { + if("OUTBOUND".equals(query.dataFamily()))return Map.of(); + List warehouses=config.getWarehouses().keySet().stream() + .filter(w->query.warehouseCode()==null||w.equals(query.warehouseCode())).sorted().toList(); + if(warehouses.isEmpty())return Map.of(); + LocalDate until=LocalDate.now(clock.withZone(BUSINESS_ZONE)),from=until.minusDays(30); + Map> dates=new HashMap<>(); + for(InventorySample sample:mapper.inventoryDailySamples(query.sourceAccount(),warehouses,from,until)) { + if(sample.sampleDate()!=null&&!sample.sampleDate().isBefore(from)&&sample.sampleDate().isBefore(until)) + dates.computeIfAbsent(sample.warehouseCode(),k->new HashSet<>()).add(sample.sampleDate()); + } + Map starts=new HashMap<>(); + for(InventorySample sample:mapper.inventoryAccumulationStarts(query.sourceAccount(),warehouses)) + if(sample.sampleDate()!=null)starts.put(sample.warehouseCode(),sample.sampleDate()); + Map result=new HashMap<>(); + for(String warehouse:warehouses) { + Set observed=dates.getOrDefault(warehouse,Set.of()); + List missing=from.datesUntil(until).filter(d->!observed.contains(d)).toList(); + result.put(warehouse,new CollectionResponses.InventoryDailySamples(from,until,30,30-missing.size(),missing,starts.get(warehouse))); + } + return result; + } + + public CollectionResponses.Page tasks(CollectionQuery query) { + validate(query,true); + var f=filter(query); Bound bound=bound(query,false,f); + if(bound==null)return empty(query); + var scopes=scopeMap(query); var protection=protection(query.sourceAccount()); + List tasks=mapper.tasks(f,bound);Context context=context(query.sourceAccount(),tasks,List.of()); + var rows=tasks.stream().map(t->taskView(t,scopes.get(t.dataFamily()),protection,context)).toList(); + return new CollectionResponses.Page<>(current(query),size(query),mapper.taskCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows); + } + public CollectionResponses.TaskDetail taskDetail(String taskNo,CollectionQuery query) { + validate(query,true); + if(taskNo==null||taskNo.isBlank()||taskNo.length()>64)throw new BizException("任务号无效"); + Task task=mapper.task(query.sourceAccount(),query.warehouseCode(),taskNo); + if(task==null)throw new BizException("任务不存在或不属于所选仓库"); + Scope scope=scopeMap(query).get(task.dataFamily()); + if(scope==null||(query.dataFamily()!=null&&!query.dataFamily().equals(task.dataFamily())))throw new BizException("任务范围与所选仓库不一致"); + Context context=context(query.sourceAccount(),List.of(task),List.of()); + Task shared=relatedUpstream(task,context); + var protection=protection(query.sourceAccount()); + return new CollectionResponses.TaskDetail(taskView(task,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection,clock.instant()); + } + public CollectionResponses.Page runs(CollectionQuery query) { + validate(query,true); + var f=filter(query); Bound bound=bound(query,true,f); + if(bound==null)return empty(query); + List runs=mapper.runs(f,bound); + Map tasks=new HashMap<>();List taskIds=runs.stream().map(Attempt::taskId).distinct().toList(); + if(!taskIds.isEmpty())for(Task t:mapper.tasksByIds(query.sourceAccount(),taskIds))tasks.put(t.id(),t); + Map scopes=scopeMap(query);var protection=protection(query.sourceAccount()); + Context context=context(query.sourceAccount(),new ArrayList<>(tasks.values()),List.of()); + var rows=runs.stream().map(r->{Task t=tasks.get(r.taskId());return attemptView(r,t, + t!=null&&retryBlock(t,scopes.get(t.dataFamily()),protection,context)==null);}).toList(); + return new CollectionResponses.Page<>(current(query),size(query),mapper.runCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows); + } + private CollectionResponses.Page empty(CollectionQuery q) { + // A zero id freezes an empty first page; later inserts cannot enter it. + return new CollectionResponses.Page<>(current(q),size(q),0,clock.instant(),0L,clock.instant(),List.of()); + } + private Map scopeMap(CollectionQuery q) { + Map scopes=new HashMap<>(); + for(Scope scope:mapper.scopes(q.sourceAccount(),q.warehouseCode()))scopes.put(scope.dataFamily(),scope); + return scopes; + } + private boolean sharedWindowRelated(Task active,Scope scope) { + if(active==null||scope==null||active.queryFromUtc()==null||active.queryUntilUtc()==null)return false; + return overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.initialFromUtc(),scope.initialUntilUtc()) + ||overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.discoveryFromUtc(),scope.discoveryUntilUtc()); + } + private static boolean overlaps(LocalDateTime from,LocalDateTime until,LocalDateTime otherFrom,LocalDateTime otherUntil) { + return otherFrom!=null&&otherUntil!=null&&from.isBefore(otherUntil)&&until.isAfter(otherFrom); + } + private String reviewStatus(Scope scope) { + if(scope==null||scope.reviewTargetDays()==null||scope.reviewTargetDays()<1)return "UNKNOWN"; + if(scope.knownOrderRoundFinishedAtUtc()==null||scope.historyRoundFinishedAtUtc()==null)return "PENDING"; + Instant deadline=clock.instant().minusSeconds(scope.reviewTargetDays()*86400L); + return utc(scope.knownOrderRoundFinishedAtUtc()).isBefore(deadline)||utc(scope.historyRoundFinishedAtUtc()).isBefore(deadline)?"OVERDUE":"REVIEWED"; + } + private Task relatedUpstream(Task task,Context context) { + if(task.upstreamTaskId()==null)return null; + Task parent=context.tasks().get(task.upstreamTaskId()); + return parent!=null&&"ACCOUNT_DISCOVERY".equals(parent.taskRole())&&Objects.equals(task.sourceAccount(),parent.sourceAccount())?parent:null; + } + private CollectionResponses.Task taskView(Task t,Scope scope,CollectionResponses.Protection protection,Context context) { + Attempt latest=context.latest().get(t.id()); + String blocked=retryBlock(t,scope,protection,context); + Task root=t.validationRootTaskId()==null?null:context.tasks().get(t.validationRootTaskId()); + Attempt result=context.published().get(t.resultPublishedRunId()); + String resultState=t.resultPublishedRunId()==null?"NOT_PUBLISHED":result==null||scope==null||!Objects.equals(scope.id(),result.scopeId()) + ||!Objects.equals(t.warehouseCode(),result.warehouseCode())||!Objects.equals(t.scopeHash(),result.scopeHash())?"RESULT_UNAVAILABLE" + :latest!=null&&"REUSED".equals(latest.status())?"REUSED":latest!=null&&"PUBLISHED".equals(latest.status())?"PUBLISHED":"RESULT_AVAILABLE"; + return new CollectionResponses.Task(t.taskNo(),t.taskType(),t.dataFamily(),t.warehouseCode(),t.taskRole(),t.executionPurpose(), + utc(t.queryFromUtc()),utc(t.queryUntilUtc()),t.status(),t.applyState(),t.attemptCount(),latest==null?null:latest.attemptNo(), + resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()), + blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()), + validation(root==null?t:root),latest==null?null:attemptView(latest,t,blocked==null)); + } + private String retryBlock(Task task,Scope scope,CollectionResponses.Protection protection,Context context) { + if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK"; + if(!"BUSINESS".equals(task.executionPurpose()))return "VALIDATION_NOT_RETRYABLE"; + String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled; + if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS"; + if(!Set.of("DEAD","RETRY_WAIT").contains(task.status()))return "TASK_NOT_RETRYABLE"; + if(scope==null||!Objects.equals(scope.scopeHash(),task.scopeHash()))return "SCOPE_CHANGED"; + if(scope.leaseOwner()!=null&&scope.leaseUntilUtc()!=null&&clock.instant().isBefore(utc(scope.leaseUntilUtc())))return "SCOPE_LEASE_BUSY"; + Task parent=relatedUpstream(task,context); + if(task.upstreamTaskId()!=null&&(parent==null||!"SUCCEEDED".equals(parent.status())))return "UPSTREAM_NOT_READY"; + return null; + } + private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) { + boolean terminal=TERMINAL.contains(run.status()); + boolean same=task!=null&&Objects.equals(run.attemptNo(),task.attemptCount()); + Map progress=progress(terminal?run.progressSnapshotJson():same?task.progressJson():null); + Instant updated=terminal?progressInstant(progress,"summaryAsOf"):same?utc(task.progressUpdatedAtUtc()):null; + Instant started=utc(run.fetchStartedAtUtc()),finished=utc(run.finishedAtUtc()),heartbeat=utc(run.heartbeatAtUtc()); + String health=terminal?"TERMINATED":heartbeat==null||!clock.instant().isBefore(heartbeat.plusSeconds(config.getLeaseSeconds()))?"UNCONFIRMED":"HEALTHY"; + Long elapsed=started==null?null:Math.max(0,Duration.between(started,finished==null?clock.instant():finished).getSeconds()); + return new CollectionResponses.Attempt(run.id(),run.taskNo(),run.attemptNo(),run.status(),run.lastPhase(),run.executionPurpose(), + started,finished,progressInstant(progress,"stageStartedAt"),updated,heartbeat,health,elapsed,run.errorCode(),safeMessage(run.errorMessage()), + retryAllowed&&same&&"BUSINESS".equals(run.executionPurpose())&&Set.of("FAILED","ABORTED","YIELDED").contains(run.status()), + run.coverageStatus(),utc(run.coveredFromUtc()),utc(run.coveredUntilUtc()),utc(run.publishedAtUtc()),progress); + } + private String safeMessage(String error) { + if(error==null)return null; + // Errors are already sanitized by the write side; avoid exposing arbitrary payloads here. + return "采集尝试失败,请根据错误码查看受控诊断记录"; + } + private Instant progressInstant(Map progress,String field) { + Object value=progress.get(field); + try{return value instanceof String text?Instant.parse(text):null;}catch(DateTimeException error){return null;} + } + private CollectionResponses.Protection protection(String account) { + var p=mapper.protection(account); + JsonNode budget=p==null?null:boundedObject(p.apiBudgetJson()); + return new CollectionResponses.Protection(p==null?null:utc(p.apiCooldownUntilUtc()),p==null?null:p.apiPauseReason(), + config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"), + budget==null||!budget.path("budgetDate").isTextual()?null:budget.path("budgetDate").textValue()); + } + private String executionBlock(String warehouse,CollectionResponses.Protection protection) { + if("OFF".equals(config.getCollectionMode()))return "COLLECTION_OFF"; + if(!Set.of("BUSINESS","VALIDATION").contains(config.getCollectionMode()))return "COLLECTION_MODE_INVALID"; + if("BUSINESS".equals(config.getCollectionMode())&&!"production".equals(config.getEnvironment()))return "BUSINESS_ENVIRONMENT_REJECTED"; + if(!config.isExecutionEnabled())return "EXECUTION_DISABLED"; + if(!config.getWarehouses().containsKey(warehouse))return "WAREHOUSE_NOT_CONFIGURED"; + var w=config.getWarehouses().get(warehouse); + if(!"Asia/Shanghai".equals(w.getBusinessTimezone())||!"Asia/Shanghai".equals(w.getSourceTimezone())||!"PCS".equals(config.getQuantityUnit()))return "TIMEZONE_OR_UNIT_NOT_CONFIRMED"; + if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED"; + if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; + if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() + ||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID"; + if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED"; + if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate()) + &&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED"; + return null; + } + private Instant nextSchedule(String warehouse,String family) { + var w=config.getWarehouses().get(warehouse); + try {var next=CronExpression.parse("OUTBOUND".equals(family)?w.getOutboundCron():w.getInventoryCron()).next(clock.instant().atZone(BUSINESS_ZONE));return next==null?null:next.toInstant();} + catch(IllegalArgumentException ex){return null;} + } + private CollectionReadMapper.Filter filter(CollectionQuery q) { + long offset=(long)(current(q)-1)*size(q);if(offset>Integer.MAX_VALUE)throw new BizException("分页范围过大"); + return new CollectionReadMapper.Filter(q.sourceAccount(),q.warehouseCode(),q.dataFamily(),q.taskType(),q.status(),q.taskNo(), + q.dateFrom()==null?null:LocalDateTime.ofInstant(q.dateFrom().atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC), + q.dateUntil()==null?null:LocalDateTime.ofInstant(q.dateUntil().plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC), + (int)offset,size(q)); + } + private Bound bound(CollectionQuery q,boolean run,CollectionReadMapper.Filter f) { + if(q.listUpperId()!=null)return new Bound(LocalDateTime.ofInstant(q.listUpperCreatedAt(),ZoneOffset.UTC),q.listUpperId()); + return run?mapper.runBound(f):mapper.taskBound(f); + } + private void validate(CollectionQuery q,boolean warehouseRequired) { + if(q==null||q.sourceAccount()==null||q.sourceAccount().isBlank()||!q.sourceAccount().equals(config.getSourceAccount()))throw new BizException("来源账号未配置或无权访问"); + if(warehouseRequired&&(q.warehouseCode()==null||q.warehouseCode().isBlank()))throw new BizException("必须选择仓库"); + if(q.warehouseCode()!=null&&!config.getWarehouses().containsKey(q.warehouseCode()))throw new BizException("仓库不在配置范围内"); + if(q.dataFamily()!=null&&!Set.of("OUTBOUND","INVENTORY").contains(q.dataFamily()))throw new BizException("任务族无效"); + if(current(q)<1||size(q)<1||size(q)>100)throw new BizException("分页参数无效"); + if((q.listUpperCreatedAt()==null)!=(q.listUpperId()==null)||q.listUpperId()!=null&&q.listUpperId()<0)throw new BizException("分页快照上界无效"); + if(current(q)>1&&q.listUpperId()==null)throw new BizException("翻页必须携带首屏快照上界"); + if(q.dateFrom()!=null&&q.dateUntil()!=null&&q.dateUntil().isBefore(q.dateFrom()))throw new BizException("创建日期范围倒置"); + if(q.taskNo()!=null&&(q.taskNo().isBlank()||q.taskNo().length()>64))throw new BizException("任务号无效"); + } + private static int current(CollectionQuery q){return q.current()==null?1:q.current();} + private static int size(CollectionQuery q){return q.size()==null?20:q.size();} + private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);} + + /** Unknown/invalid quantities stay null; arbitrary raw payload fields never pass through. */ + Map progress(String source) { + JsonNode root=boundedObject(source);if(root==null)return Map.of(); + Map result=projectProgress(root); + if(root.path("stages").isArray()) { + List> stages=new ArrayList<>(); + if(root.path("stages").size()>16)return Map.of(); + root.path("stages").forEach(stage->{if(stage.isObject())stages.add(projectProgress(stage));}); + result.put("stages",stages); + } + return Collections.unmodifiableMap(result); + } + private Map projectProgress(JsonNode node) { + Map result=new LinkedHashMap<>(); + for(String field:PROGRESS_FIELDS) { + JsonNode value=node.get(field);if(value==null)continue; + if(COUNTS.contains(field))result.put(field,value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null); + else if(Set.of("totalKnown","directoryComplete").contains(field))result.put(field,value.isBoolean()?value.booleanValue():null); + else if(value.isTextual()&&value.textValue().length()<=128)result.put(field,value.textValue()); + } + if(result.containsKey("unit")&&!UNITS.contains(result.get("unit")))result.put("unit",null); + if(result.containsKey("countUnit")&&!UNITS.contains(result.get("countUnit")))result.put("countUnit",null); + if(result.containsKey("countBasis")&&!Set.of("TASK_CUMULATIVE","ATTEMPT_ONLY").contains(result.get("countBasis")))result.put("countBasis",null); + if(result.containsKey("progressScope")&&!Set.of("ACCOUNT_SHARED","WAREHOUSE").contains(result.get("progressScope")))result.put("progressScope",null); + if(!Boolean.TRUE.equals(result.get("totalKnown")))result.put("total",null); + if("NONE".equals(result.get("unit"))) { + result.put("completed",null);result.put("total",null);result.put("totalKnown",false); + } + return result; + } + private JsonNode boundedObject(String value) { + if(value==null||value.getBytes(StandardCharsets.UTF_8).length>8192)return null; + try {JsonNode node=json.readTree(value);return node!=null&&node.isObject()?node:null;}catch(Exception ex){return null;} + } + private CollectionResponses.Validation validation(Task root) { + if(!"VALIDATION".equals(root.executionPurpose()))return null; + JsonNode budget=boundedObject(root.validationBudgetJson()); + Map limits=new LinkedHashMap<>(),consumed=new LinkedHashMap<>(); + for(String key:List.of("maxPages","maxPageSize","maxDistinctDetails","maxDetailHttp","maxInventoryPagesPerWarehouse","maxHttp"))limits.put(key,number(budget,key)); + for(String key:List.of("pages","detailHttp","http","confirmedHttp"))consumed.put(key,number(budget,key)); + consumed.put("distinctDetails",budget==null||!budget.path("detailKeys").isArray()?null:(long)budget.path("detailKeys").size()); + // detailKeys themselves contain upstream identities and are never exposed. + return new CollectionResponses.Validation(root.taskNo(),Collections.unmodifiableMap(limits),Collections.unmodifiableMap(consumed), + budgetInstant(budget,"deadlineAtUtc"),budget==null?null:fixedReason(budget.path("stopReason"))); + } + private String fixedReason(JsonNode value) { + if(value==null||value.isNull()||!value.isTextual())return null; + return value.textValue().matches("[A-Z0-9_]{1,64}")?value.textValue():"STOP_REASON_UNAVAILABLE"; + } + private Long number(JsonNode root,String field) { + JsonNode value=root==null?null:root.get(field); + return value!=null&&value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null; + } + private Instant budgetInstant(JsonNode root,String field) { + try{return root==null||!root.path(field).isTextual()?null:utc(LocalDateTime.parse(root.path(field).textValue()));}catch(DateTimeException ex){return null;} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java new file mode 100644 index 000000000..c1eeb40b4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.List; +import org.apache.ibatis.annotations.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*; + +@Mapper +public interface CollectionReadMapper { + String DAILY_SAMPLE_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_scope s ON s.id=r.scope_id" + + " AND s.source_account=r.source_account AND s.warehouse_code=r.warehouse_code AND s.data_family='INVENTORY'" + + " WHERE r.source_account=#{account} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS'" + + " AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE'" + + " AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1'" + + " AND r.warehouse_code IN #{w}"; + @Select("") + List inventoryDailySamples(@Param("account") String account, + @Param("warehouses") List warehouses, @Param("from") LocalDate from, @Param("until") LocalDate until); + @Select("") + List inventoryAccumulationStarts(@Param("account") String account, + @Param("warehouses") List warehouses); + record Filter(String account, String warehouse, String family, String type, String status, + String taskNo, LocalDateTime from, LocalDateTime until, int offset, int size) {} + String SCOPE = "id,warehouse_code,data_family,scope_hash,business_timezone,schedule_cron,stale_after_seconds," + + "initial_from_utc,initial_until_utc,discovery_from_utc,discovery_until_utc,rebuild_required,published_run_id," + + "known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days,lease_owner,lease_until_utc"; + String TASK = "t.id,t.task_no,t.source_account,t.warehouse_code,s.data_family,t.task_role,t.task_type," + + "t.execution_purpose,t.scope_hash,t.status,t.apply_state,t.attempt_count,t.query_from_utc,t.query_until_utc," + + "t.next_retry_at_utc,t.upstream_task_id,t.validation_root_task_id,t.result_published_run_id," + + "t.progress_json,t.progress_updated_at_utc,t.validation_budget_json,t.created_at,t.updated_at"; + String ATTEMPT = "r.id,r.task_id,t.task_no,r.attempt_no,r.status,r.last_phase,r.execution_purpose," + + "r.fetch_started_at_utc,r.fetch_finished_at_utc,r.build_started_at_utc,r.finished_at_utc," + + "r.published_at_utc,r.heartbeat_at_utc,r.progress_snapshot_json,r.coverage_status," + + "r.covered_from_utc,r.covered_until_utc,r.error_code,r.error_message,r.created_at,r.warehouse_code,r.scope_id,r.scope_hash"; + String TASK_FROM = " FROM ll_selfop_sync_task t JOIN ll_selfop_sync_scope s ON s.id=t.scope_id " + + "AND s.source_account=t.source_account AND s.warehouse_code=t.warehouse_code "; + String RUN_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id " + + "AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code " + + "JOIN ll_selfop_sync_scope s ON s.id=r.scope_id AND s.id=t.scope_id "; + String FILTER = " WHERE t.source_account=#{f.account} AND t.warehouse_code=#{f.warehouse} " + + "AND t.task_role='WAREHOUSE_SYNC' " + + "AND s.data_family=#{f.family} " + + "AND t.task_type=#{f.type} " + + "AND t.task_no=#{f.taskNo} "; + String TASK_FILTER = FILTER + "AND t.status=#{f.status} " + + "AND t.created_at >= #{f.from} " + + "AND t.created_at < #{f.until} "; + String RUN_FILTER = FILTER + "AND r.status=#{f.status} " + + "AND r.created_at >= #{f.from} " + + "AND r.created_at < #{f.until} "; + String TASK_BOUND = " AND (t.created_at < #{b.createdAt} OR (t.created_at=#{b.createdAt} AND t.id <= #{b.id})) "; + String RUN_BOUND = " AND (r.created_at < #{b.createdAt} OR (r.created_at=#{b.createdAt} AND r.id <= #{b.id})) "; + + @Select("") + List scopes(@Param("account") String account, @Param("warehouse") String warehouse); + @Select("") + Bound taskBound(@Param("f") Filter filter); + @Select("") + Bound runBound(@Param("f") Filter filter); + @Select("") + long taskCount(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("") + List tasks(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("") + long runCount(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("") + List runs(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("SELECT " + TASK + TASK_FROM + " WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} AND t.task_no=#{taskNo}") + Task task(@Param("account") String account, @Param("warehouse") String warehouse, @Param("taskNo") String taskNo); + @Select("SELECT api_cooldown_until_utc,api_pause_reason,api_budget_json FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} LIMIT 1") + Protection protection(@Param("account") String account); + + @Select("") + List tasksByIds(@Param("account") String account,@Param("ids") List ids); + @Select("") + List latestAttempts(@Param("account") String account,@Param("ids") List ids); + @Select("") + List publishedRuns(@Param("account") String account,@Param("ids") List ids); + @Select("") + List currentTasks(@Param("account") String account,@Param("ids") List ids); + @Select("") + List lastSuccessfulTasks(@Param("account") String account,@Param("ids") List ids); + // The one account active task is separately related to warehouses by actual persisted windows. + @Select("SELECT " + TASK + " FROM ll_selfop_outbound_discovery_scope d JOIN ll_selfop_sync_task t ON t.id=d.active_task_id" + + " AND t.source_account=d.source_account LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id" + + " WHERE d.source_account=#{account} AND t.task_role='ACCOUNT_DISCOVERY' LIMIT 1") + Task sharedActiveAccount(@Param("account") String account); + @Select("") + List barrierWarehouses(@Param("account") String account,@Param("warehouses") List warehouses); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java new file mode 100644 index 000000000..811c5be78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDateTime; +import java.time.LocalDate; + +/** Narrow read projections: no raw manifests, stage payloads or business detail tables. */ +public final class CollectionReadRows { + private CollectionReadRows() {} + public record Bound(LocalDateTime createdAt, Long id) {} + public record InventorySample(String warehouseCode, LocalDate sampleDate) {} + public record Scope(Long id, String warehouseCode, String dataFamily, String scopeHash, + String businessTimezone, String scheduleCron, Integer staleAfterSeconds, + LocalDateTime initialFromUtc, LocalDateTime initialUntilUtc, + LocalDateTime discoveryFromUtc, LocalDateTime discoveryUntilUtc, + Integer rebuildRequired, Long publishedRunId, + LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc, Integer reviewTargetDays, + String leaseOwner, LocalDateTime leaseUntilUtc) {} + public record Task(Long id, String taskNo, String sourceAccount, String warehouseCode, + String dataFamily, String taskRole, String taskType, String executionPurpose, + String scopeHash, String status, String applyState, Integer attemptCount, + LocalDateTime queryFromUtc, LocalDateTime queryUntilUtc, LocalDateTime nextRetryAtUtc, + Long upstreamTaskId, Long validationRootTaskId, Long resultPublishedRunId, + String progressJson, LocalDateTime progressUpdatedAtUtc, String validationBudgetJson, + LocalDateTime createdAt, LocalDateTime updatedAt) {} + public record Attempt(Long id, Long taskId, String taskNo, Integer attemptNo, + String status, String lastPhase, String executionPurpose, + LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc, + LocalDateTime buildStartedAtUtc, LocalDateTime finishedAtUtc, + LocalDateTime publishedAtUtc, LocalDateTime heartbeatAtUtc, + String progressSnapshotJson, String coverageStatus, + LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc, + String errorCode, String errorMessage, LocalDateTime createdAt, String warehouseCode, Long scopeId, String scopeHash) {} + public record Protection(LocalDateTime apiCooldownUntilUtc, String apiPauseReason, String apiBudgetJson) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java new file mode 100644 index 000000000..3d9aec8b4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public final class CollectionResponses { + private CollectionResponses() {} + public record Page(int current, int size, long total, Instant listUpperCreatedAt, + Long listUpperId, Instant observedAt, List records) {} + public record Overview(String sourceAccount, String environmentLabel, String collectionMode, + Instant observedAt, List warehouses) {} + public record Warehouse(String warehouseCode, String warehouseName, String dataFamily, + boolean executionEnabled, boolean publicationEnabled, String disabledReason, + Instant nextScheduledAt, Instant observedAt, Instant initialFrom, Instant initialUntil, + String initialCoverageStatus, Instant salesCoveredFrom, Instant salesCoveredUntil, + String lastSuccessfulTaskNo, Instant lastSucceededAt, Instant lastPublishedAt, + Instant inventoryObservedFrom, Instant inventoryObservedUntil, String dataFreshness, + boolean rebuildRequired, String revisionReviewStatus, Task currentTask, + Task sharedUpstream, Protection protection, InventoryDailySamples inventoryDailySamples) {} + public record InventoryDailySamples(LocalDate windowFrom, LocalDate windowUntil, int expectedDays, + int sampleCount, List missingDates, LocalDate accumulationStartedOn) {} + public record Task(String taskNo, String taskType, String dataFamily, String warehouseCode, + String taskRole, String executionPurpose, Instant queryFrom, Instant queryUntil, + String taskStatus, String applyState, Integer attemptCount, Integer latestAttemptNo, + String resultPublicationState, Long resultPublishedRunId, Instant nextRetryAt, + List allowedActions, String actionBlockReason, Instant progressUpdatedAt, + Map progress, Validation validation, Attempt latestAttempt) {} + public record TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {} + public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase, + String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt, + Instant progressUpdatedAt, Instant heartbeatAt, String executionHealth, Long elapsedSeconds, + String errorCode, String failureSummary, boolean retryable, String coverageStatus, + Instant coveredFrom, Instant coveredUntil, Instant publishedAt, Map progress) {} + public record Protection(Instant accountCooldownUntil, String accountPauseReason, + Integer dailyRequestBudget, Long reservedHttpRequests, Long confirmedHttpRequests, String budgetDate) {} + public record Validation(String validationSessionNo, Map budgetLimits, + Map budgetConsumed, Instant deadlineAt, String stopReason) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java new file mode 100644 index 000000000..c74863d35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import java.time.LocalDateTime; +/** Fixed reason; committed reservations are never refunded after an ambiguous outcome. */ +public final class EgressRejectedException extends RuntimeException { + private final boolean sampleDone; private final LocalDateTime waitUntilUtc; + public EgressRejectedException(String code, boolean sampleDone, LocalDateTime waitUntilUtc) {super(code);this.sampleDone=sampleDone;this.waitUntilUtc=waitUntilUtc;} + public String code(){return getMessage();} public boolean sampleDone(){return sampleDone;} public LocalDateTime waitUntilUtc(){return waitUntilUtc;} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java new file mode 100644 index 000000000..50228a3b3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import java.time.*; +/** Bounded durable account journal: only one outstanding attempt; old IDs cannot match a new permit. */ +public final class SelfopApiBudget { + public int dailyLimit,minIntervalMs; public String configHash; + public LocalDate budgetDate; public long reservedHttp,confirmedHttp; public int rateLimitFailures; + public LocalDateTime lastRequestAtUtc; public String importedGrantId; public Pending pending; + public static final class Pending { + public SelfopEgressDtos.EgressContext context; public SelfopEgressDtos.Operation operation; public String requestHash; public String internalRequestHash; public String completionHash; + public Long taskId,runId; public LocalDateTime expiresAtUtc; public boolean consumed; public boolean completed; public boolean abandoned; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java new file mode 100644 index 000000000..591fd1485 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import java.time.ZoneOffset; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/internal/lastleg/self-operated-analytics/egress") +public class SelfopEgressController { + private final InternalServiceTokenGuard guard;private final SelfopEgressService service; + public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;} + @PostMapping("/authorize") public ApiResponse authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}} + @PostMapping("/complete") public ApiResponse complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java new file mode 100644 index 000000000..438f90d82 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import java.time.Instant; +public final class SelfopEgressDtos { + private SelfopEgressDtos(){} + public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY} + public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){} + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) + public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){} + public record AuthorizationRequest(EgressContext context,Operation operation,String requestHash){} + public record AttemptPermit(EgressContext context,Operation operation,String requestHash,int allowedAttempts,Instant expiresAt,boolean exclusiveConfirmed,boolean budgetConsumed){} + public record CompletionRequest(EgressContext context,Operation operation,String requestHash,int actualHttpAttempts,String errorCode,Integer upstreamStatus,Integer sourceCode,boolean accountPaused,Instant cooldownUntil){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java new file mode 100644 index 000000000..ce9592030 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +@Mapper +public interface SelfopEgressMapper { + @Select("SELECT * FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} FOR UPDATE") SelfopOutboundDiscoveryScope lockAccount(String account); + @Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE") SelfopSyncTask lockTask(Long id); + @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") SelfopSyncRun lockRun(Long id); + @Select("SELECT COUNT(*) FROM ll_selfop_sync_scope WHERE id=#{scopeId} AND fence_token=#{fence} AND lease_until_utc>#{now}") int warehouseFence(@Param("scopeId") Long scopeId,@Param("fence") Long fence,@Param("taskId") Long taskId,@Param("now") LocalDateTime now); + @Update("UPDATE ll_selfop_outbound_discovery_scope SET api_budget_json=#{apiBudgetJson},api_cooldown_until_utc=#{apiCooldownUntilUtc},api_pause_reason=#{apiPauseReason},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveAccount(SelfopOutboundDiscoveryScope row); + @Update("UPDATE ll_selfop_sync_task SET validation_budget_json=#{validationBudgetJson},status=#{status},next_retry_at_utc=#{nextRetryAtUtc},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveRoot(SelfopSyncTask row); + @Update("UPDATE ll_selfop_sync_run SET status=#{status},error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT progress_json FROM ll_selfop_sync_task WHERE id=task_id) WHERE task_id IN (SELECT id FROM ll_selfop_sync_task WHERE validation_root_task_id=#{root}) AND status IN ('FETCHING','VALIDATED','YIELDED')") int terminateValidationRuns(@Param("root") Long root,@Param("status") String status,@Param("reason") String reason,@Param("now") LocalDateTime now); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java new file mode 100644 index 000000000..9bd106d97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +@Service +public class SelfopEgressService { + private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock; + @Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());} + SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;} + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);} + @Transactional(noRollbackFor=EgressRejectedException.class) + public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){ + if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID"); + LocalDateTime now=now();gate(context.sourceAccount(),context.executionPurpose(),context.exclusiveGrantId(),now); + if(operation==null||requestHash==null||!requestHash.matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_INVALID"); + var account=account(context.sourceAccount());var budget=budget(account,now);save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount()); + var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId());verify(task,run,root,account,now); + checkAccount(account,budget,now); + SelfopValidationBudget validation=null; + if("VALIDATION".equals(context.executionPurpose())){ + validation=validation(root);String limit=validation.limit(now,operation,pageSize,warehouseCode,detailKey); + if(limit!=null){stop(root,validation,limit,false,now);throw new EgressRejectedException(limit,true,null);} + if(operation==Operation.PRODUCT_INVENTORY&&(warehouseCode==null||!config.getWarehouses().containsKey(warehouseCode)))throw reject("EGRESS_WAREHOUSE_INVALID"); + if(operation==Operation.OUTBOUND_DETAIL&&(detailKey==null||detailKey.isBlank()))throw reject("EGRESS_DETAIL_IDENTITY_INVALID"); + } + int interval="VALIDATION".equals(context.executionPurpose())?Math.max(5000,budget.minIntervalMs):budget.minIntervalMs; + if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000)); + if(budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); + var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); + var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); + if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); + if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; + budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now; + if(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);} + save(account,budget);return outbound; + } + @Transactional(noRollbackFor=EgressRejectedException.class) + public AttemptPermit authorize(AuthorizationRequest request){ + requireRequest(request);var c=request.context();var now=now();gate(c.sourceAccount(),c.executionPurpose(),c.exclusiveGrantId(),now); + var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pendingForAuthorization(budget,c,request.operation()); + var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); + var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);verify(task,run,root,account,now); + if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); + if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED"); + if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}} + if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID"); + pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(30);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget); + return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true); + } + @Transactional(noRollbackFor=EgressRejectedException.class) + public void complete(CompletionRequest request){ + if(request==null||request.context()==null||request.actualHttpAttempts()<0||request.actualHttpAttempts()>1)throw reject("EGRESS_COMPLETION_INVALID"); + var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pending(budget,c,request.operation(),request.requestHash()); + String completionHash=SelfopSupport.hash(request); + if(pending.completed&&!pending.abandoned){ + if(!Objects.equals(pending.completionHash,completionHash))throw reject("EGRESS_COMPLETION_CONFLICT"); + return; + } + if(!pending.consumed)throw reject("EGRESS_COMPLETION_NOT_AUTHORIZED"); + pending.completed=true;pending.abandoned=false;pending.completionHash=completionHash;budget.confirmedHttp+=request.actualHttpAttempts(); + var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); + SelfopValidationBudget validation="VALIDATION".equals(c.executionPurpose())?validation(root):null; + if(validation!=null){validation.confirmedHttp+=request.actualHttpAttempts();root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);} + boolean auth=Integer.valueOf(401).equals(request.upstreamStatus())||Integer.valueOf(403).equals(request.upstreamStatus()) + ||"LINGXING_WMS_AUTHORIZATION_REJECTED".equals(request.errorCode())||"LINGXING_WMS_SIGNATURE_REJECTED".equals(request.errorCode()); + boolean rate=!auth&&(Integer.valueOf(429).equals(request.upstreamStatus())||Integer.valueOf(31001).equals(request.sourceCode()) + ||Integer.valueOf(429).equals(request.sourceCode())||"LINGXING_WMS_HTTP_RATE_LIMITED".equals(request.errorCode()) + ||"LINGXING_WMS_SOURCE_RATE_LIMITED".equals(request.errorCode())); + if(rate){ + budget.rateLimitFailures++;var until=now.plusSeconds(Math.max(1,config.getCooldownSeconds())*(1L<=config.getMaxRateLimitFailures()?"RATE_LIMIT_REQUIRES_REVIEW":"RATE_LIMIT_COOLDOWN"); + } + if(auth)account.setApiPauseReason("AUTHORIZATION_REQUIRES_REVIEW"); + else if(request.accountPaused()&&!rate&&recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("EGRESS_REQUIRES_REVIEW"); + if(validation!=null&&(request.errorCode()!=null||rate||request.accountPaused()||request.upstreamStatus()!=null&&request.upstreamStatus()>=400))stop(root,validation,auth?"AUTHORIZATION_REJECTED":rate?"RATE_LIMITED":"VALIDATION_PROVIDER_FAILURE",true,now); + save(account,budget); + } + /** Records the caller's uncertain result without changing the original completion or its consumption. */ + @Transactional + public void pauseUncertain(EgressContext context,Operation operation,String errorCode){ + if(context==null||operation==null)throw reject("EGRESS_CONTEXT_INVALID"); + var account=account(context.sourceAccount()); + if(!Objects.equals(account.getSourceAccount(),context.sourceAccount())||account.getApiBudgetJson()==null)throw reject("EGRESS_PERMIT_BINDING_INVALID"); + var budget=SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class); + var pending=pendingForAuthorization(budget,context,operation); + // A durable completion already confirms the supplier rate limit. Keep its recoverable cooldown. + boolean confirmedRate=pending.completed&&!pending.abandoned&&pending.completionHash!=null + &&Set.of("LINGXING_WMS_HTTP_RATE_LIMITED","LINGXING_WMS_SOURCE_RATE_LIMITED").contains(errorCode==null?"":errorCode) + &&Set.of("RATE_LIMIT_COOLDOWN","RATE_LIMIT_REQUIRES_REVIEW").contains(account.getApiPauseReason()==null?"":account.getApiPauseReason()); + if(confirmedRate)return; + if(recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("EGRESS_REQUIRES_REVIEW"); + if(mapper.saveAccount(account)!=1)throw reject("EGRESS_ACCOUNT_WRITE_FAILED"); + } + @Transactional(noRollbackFor=EgressRejectedException.class) + public void failValidation(Long rootTaskId,String code){var initial=mapper.lockTask(rootTaskId);if(initial==null)throw reject("VALIDATION_ROOT_MISSING");/* caller is no longer holding collection locks */var v=validation(initial);stop(initial,v,safeReason(code),true,now());} + @Transactional(noRollbackFor=EgressRejectedException.class) + public void finishSample(Long rootTaskId,String reason){var root=mapper.lockTask(rootTaskId);var v=validation(root);if("DEAD".equals(root.getStatus()))return;stop(root,v,safeReason(reason),false,now());} + private void gate(String account,String purpose,String grant,LocalDateTime now){ + if("OFF".equals(config.getCollectionMode())||!Objects.equals(config.getCollectionMode(),purpose)||!("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose)))throw reject("EGRESS_MODE_REJECTED"); + if("BUSINESS".equals(purpose)&&!"production".equals(config.getEnvironment()))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); + if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH"); + if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + if(config.getDailyRequestBudget()<1||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); + } + private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;} + private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){ + var b=account.getApiBudgetJson()==null?new SelfopApiBudget():SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class); + if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID"); + LocalDate day=now.plusHours(8).toLocalDate(); + if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;} + if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget(); + b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs()); + b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds())); + if(!Objects.equals(config.getExclusiveGrantId(),b.importedGrantId)){ + if(config.getTransferredBudgetDate()==null||!config.getTransferredBudgetDate().equals(day)||config.getTransferredDailyHttpCount()<0)throw reject("HANDOFF_BUDGET_DATE_UNCONFIRMED"); + b.reservedHttp=Math.max(b.reservedHttp,config.getTransferredDailyHttpCount());b.importedGrantId=config.getExclusiveGrantId(); + if(config.getTransferredCooldownUntilUtc()!=null&&(account.getApiCooldownUntilUtc()==null||account.getApiCooldownUntilUtc().isBefore(config.getTransferredCooldownUntilUtc())))account.setApiCooldownUntilUtc(config.getTransferredCooldownUntilUtc()); + if(config.getTransferredPauseReason()!=null&&!config.getTransferredPauseReason().isBlank())account.setApiPauseReason(config.getTransferredPauseReason()); + } + return b; + } + private void checkAccount(SelfopOutboundDiscoveryScope account,SelfopApiBudget b,LocalDateTime now){ + if(b.pending!=null&&!b.pending.completed){ + if(now.isBefore(b.pending.expiresAtUtc))throw new EgressRejectedException("ACCOUNT_REQUEST_INFLIGHT",false,b.pending.expiresAtUtc); + /* A lost response may still be in the provider. Conservatively retain charge and cool before another attempt. */ + b.pending.completed=true;b.pending.abandoned=true;var until=now.plusSeconds(config.getCooldownSeconds()); + if(account.getApiCooldownUntilUtc()==null||account.getApiCooldownUntilUtc().isBefore(until))account.setApiCooldownUntilUtc(until); + if(recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("REQUEST_OUTCOME_UNKNOWN");save(account,b); + } + if(account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw new EgressRejectedException("ACCOUNT_COOLDOWN_WAIT",false,account.getApiCooldownUntilUtc()); + if("RATE_LIMIT_COOLDOWN".equals(account.getApiPauseReason())||"REQUEST_OUTCOME_UNKNOWN".equals(account.getApiPauseReason()))account.setApiPauseReason(null); + if(account.getApiPauseReason()!=null)throw reject("ACCOUNT_REQUIRES_REVIEW"); + } + private SelfopSyncTask root(Long id,String purpose,String account){var t=mapper.lockTask(id);if(t==null||!Objects.equals(t.getSourceAccount(),account)||!Objects.equals(t.getExecutionPurpose(),purpose))throw reject("EGRESS_ROOT_INVALID");if("VALIDATION".equals(purpose)&&!Objects.equals(id,t.getValidationRootTaskId()))throw reject("VALIDATION_ROOT_INVALID");return t;} + private void verify(SelfopSyncTask task,SelfopSyncRun run,SelfopSyncTask root,SelfopOutboundDiscoveryScope account,LocalDateTime now){ + if(task==null||run==null||!Objects.equals(run.getTaskId(),task.getId())||!Objects.equals(run.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getExecutionPurpose(),root.getExecutionPurpose())||!Objects.equals(run.getExecutionPurpose(),task.getExecutionPurpose())||!"RUNNING".equals(task.getStatus())||!Set.of("FETCHING","VALIDATED").contains(run.getStatus()))throw reject("EGRESS_TASK_NOT_ACTIVE"); + if("VALIDATION".equals(root.getExecutionPurpose())&&(!Objects.equals(task.getValidationRootTaskId(),root.getId())||Set.of("DEAD","SAMPLE_DONE").contains(root.getStatus())))throw reject("VALIDATION_ROOT_STOPPED"); + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){if(!Objects.equals(task.getDiscoveryScopeId(),account.getId())||!Objects.equals(account.getFenceToken(),run.getFenceToken())||account.getLeaseUntilUtc()==null||!now.isBefore(account.getLeaseUntilUtc()))throw reject("EGRESS_FENCE_LOST");} + else if(!Objects.equals(task.getScopeId(),run.getScopeId())||!Objects.equals(task.getWarehouseCode(),run.getWarehouseCode())||mapper.warehouseFence(task.getScopeId(),run.getFenceToken(),task.getId(),now)!=1)throw reject("EGRESS_FENCE_LOST"); + } + private SelfopValidationBudget validation(SelfopSyncTask root){if(root==null||!"VALIDATION".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getValidationRootTaskId())||root.getValidationBudgetJson()==null)throw reject("VALIDATION_ROOT_INVALID");var b=SelfopSupport.read(root.getValidationBudgetJson(),SelfopValidationBudget.class);b.validate();return b;} + private SelfopApiBudget.Pending pendingForAuthorization(SelfopApiBudget budget,EgressContext c,Operation op){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op)throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;} + private SelfopApiBudget.Pending pending(SelfopApiBudget budget,EgressContext c,Operation op,String hash){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op||!Objects.equals(p.requestHash,hash))throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;} + private void stop(SelfopSyncTask root,SelfopValidationBudget v,String reason,boolean failed,LocalDateTime now){if("DEAD".equals(root.getStatus())||"SAMPLE_DONE".equals(root.getStatus()))return;v.stopReason=reason;root.setValidationBudgetJson(SelfopSupport.json(v));root.setStatus(failed?"DEAD":"SAMPLE_DONE");root.setNextRetryAtUtc(null);mapper.saveRoot(root);mapper.terminateValidationRuns(root.getId(),failed?"FAILED":"SAMPLE_DONE",reason,now);} + private void save(SelfopOutboundDiscoveryScope account,SelfopApiBudget b){account.setApiBudgetJson(SelfopSupport.json(b));if(mapper.saveAccount(account)!=1)throw reject("EGRESS_ACCOUNT_WRITE_FAILED");} + private static Long parseId(String id){try{return Long.valueOf(id);}catch(Exception e){throw reject("EGRESS_ROOT_ID_INVALID");}} + private static void requireRequest(AuthorizationRequest r){if(r==null||r.context()==null||r.operation()==null||r.requestHash()==null)throw reject("EGRESS_REQUEST_INVALID");} + private static String safeReason(String code){return code!=null&&code.matches("[A-Z0-9_]{1,64}")?code:"VALIDATION_FAILED";} + private static boolean recoverablePause(String reason){return reason==null||"RATE_LIMIT_COOLDOWN".equals(reason)||"REQUEST_OUTCOME_UNKNOWN".equals(reason);} + private static EgressRejectedException reject(String code){return new EgressRejectedException(code,false,null);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java new file mode 100644 index 000000000..617db45e4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import java.time.LocalDateTime; +import java.util.*; +/** Frozen, persisted once on the root task. Limits may only be lowered before creation. */ +public final class SelfopValidationBudget { + public int maxPages=10,maxPageSize=10,maxDistinctDetails=20,maxDetailHttp=20,maxInventoryPagesPerWarehouse=2,maxHttp=40; + public int pages,detailHttp,http,confirmedHttp; + public LocalDateTime startedAtUtc,deadlineAtUtc; + public String stopReason; + public Set detailKeys=new LinkedHashSet<>(); + public Map inventoryPages=new LinkedHashMap<>(); + public static String freeze(LocalDateTime now){return freeze(now,10,20,40,300);} + public static String freeze(LocalDateTime now,int pages,int details,int http,int seconds){ + if(pages<1||details<1||http<1||seconds<1)throw new IllegalArgumentException("VALIDATION_LIMIT_INVALID"); + var b=new SelfopValidationBudget();b.maxPages=Math.min(10,pages);b.maxDetailHttp=Math.min(20,details);b.maxDistinctDetails=Math.min(20,details);b.maxHttp=Math.min(40,http);b.startedAtUtc=now;b.deadlineAtUtc=now.plusSeconds(Math.min(300,seconds));return SelfopSupport.json(b); + } + public void validate(){ + if(maxPages<1||maxPages>10||maxPageSize<1||maxPageSize>10||maxDistinctDetails<1||maxDistinctDetails>20||maxDetailHttp<1||maxDetailHttp>20||maxInventoryPagesPerWarehouse<1||maxInventoryPagesPerWarehouse>2||maxHttp<1||maxHttp>40||startedAtUtc==null||deadlineAtUtc==null||deadlineAtUtc.isAfter(startedAtUtc.plusMinutes(5))||!deadlineAtUtc.isAfter(startedAtUtc)||pages<0||detailHttp<0||http<0||confirmedHttp<0||confirmedHttp>http||detailKeys==null||inventoryPages==null)throw new EgressRejectedException("VALIDATION_BUDGET_INVALID",false,null); + } + public String limit(LocalDateTime now,SelfopEgressDtos.Operation op,int size,String warehouse,String key){ + validate();if(stopReason!=null)return stopReason; + if(!now.isBefore(deadlineAtUtc))return "VALIDATION_DEADLINE"; + if(http>=maxHttp)return "VALIDATION_HTTP_LIMIT"; + if(op!=SelfopEgressDtos.Operation.OUTBOUND_DETAIL){if(size<1||size>maxPageSize)throw new EgressRejectedException("VALIDATION_PAGE_SIZE",false,null);if(pages>=maxPages)return "VALIDATION_PAGE_LIMIT";} + if(op==SelfopEgressDtos.Operation.PRODUCT_INVENTORY&&inventoryPages.getOrDefault(warehouse,0)>=maxInventoryPagesPerWarehouse)return "VALIDATION_INVENTORY_LIMIT"; + if(op==SelfopEgressDtos.Operation.OUTBOUND_DETAIL){if(detailHttp>=maxDetailHttp)return "VALIDATION_DETAIL_HTTP_LIMIT";if(!detailKeys.contains(key)&&detailKeys.size()>=maxDistinctDetails)return "VALIDATION_DETAIL_IDENTITY_LIMIT";} + return null; + } + public void consume(SelfopEgressDtos.Operation op,String warehouse,String key){http++;if(op!=SelfopEgressDtos.Operation.OUTBOUND_DETAIL)pages++;else{detailHttp++;detailKeys.add(key);}if(op==SelfopEgressDtos.Operation.PRODUCT_INVENTORY)inventoryPages.merge(warehouse,1,Integer::sum);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java new file mode 100644 index 000000000..e3807e2f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.selfoperated.analytics.retention; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface SalesRetentionMapper { + @Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE") + SelfopSyncScope scope(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") + SelfopSyncRun run(@Param("id") Long id); + @Select("SELECT id FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} AND status='PUBLISHED' AND execution_purpose='BUSINESS' AND id != #{current} ORDER BY published_at_utc DESC,id DESC LIMIT 1") + Long previous(@Param("scopeId") Long scopeId,@Param("current") Long current); + @Select(""" + SELECT r.* FROM ll_selfop_sync_run r + WHERE r.scope_id=#{scopeId} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' + AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff} + AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL + AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous}) + AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id) + OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id)) + AND NOT EXISTS ( + SELECT 1 FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id + WHERE t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code + AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED')) + AND (input.base_published_run_id=r.id OR input.reference_sales_run_id=r.id)) + ORDER BY r.superseded_at_utc,r.id LIMIT 500 + """) + List eligible(@Param("scopeId") Long scopeId,@Param("current") Long current, + @Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff); + @Select(""" + SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id + WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} + AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED')) + AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id}) + """) + long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id); + @Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}") + int deleteDaily(@Param("id") Long id,@Param("batchSize") int batchSize); + @Delete("DELETE FROM ll_selfop_sku_profile WHERE profile_run_id=#{id} LIMIT #{batchSize}") + int deleteProfiles(@Param("id") Long id,@Param("batchSize") int batchSize); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java new file mode 100644 index 000000000..0e7af298c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.selfoperated.analytics.retention; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.*; + +/** No scheduled execution. Enabled, explicitly requested calls can remove only old derived sales copies. */ +@Service +@RequiredArgsConstructor +public class SalesRetentionService { + private final SalesRetentionMapper mapper; + private final SelfopAnalyticsProperties config; + + public record Preview(Long scopeId,Long current,Long previous,List eligibleRunIds,LocalDateTime cutoff) { } + public record Deleted(Long runId,int dailyRows,int profileRows) { } + + @Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class) + public Preview preview(Long scopeId,LocalDateTime now) { + SelfopSyncScope scope=mapper.scope(scopeId); validateScope(scope); + Long previous=scope.getPublishedRunId()==null?null:mapper.previous(scopeId,scope.getPublishedRunId()); + LocalDateTime cutoff=now.minusDays(Math.max(7,config.getSalesRetentionDays())); + if(scope.getPublishedRunId()==null) return new Preview(scopeId,null,null,List.of(),cutoff); + return new Preview(scopeId,scope.getPublishedRunId(),previous, + mapper.eligible(scopeId,scope.getPublishedRunId(),previous,cutoff).stream().map(SelfopSyncRun::getId).toList(),cutoff); + } + + /** Every batch rechecks current pointers and active references while holding the shared OUTBOUND scope lock. */ + @Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class) + public Deleted deleteBatch(Long scopeId,Long runId) { + SelfopSupport.require(config.isRetentionEnabled(),"SALES_RETENTION_DISABLED"); + SelfopSyncScope scope=mapper.scope(scopeId); validateScope(scope); + SelfopSyncRun run=mapper.run(runId); + Long previous=scope.getPublishedRunId()==null?null:mapper.previous(scopeId,scope.getPublishedRunId()); + SelfopSupport.require(run!=null && Objects.equals(run.getScopeId(),scopeId) + && Objects.equals(run.getSourceAccount(),scope.getSourceAccount()) + && Objects.equals(run.getWarehouseCode(),scope.getWarehouseCode()) && "WAREHOUSE_SYNC".equals(run.getRunRole()) + && "PUBLISHED".equals(run.getStatus()) && "BUSINESS".equals(run.getExecutionPurpose()) + && run.getInventoryDailySampleDate()==null && run.getInventorySampleRuleVersion()==null + && run.getPublishedAtUtc()!=null && run.getSupersededAtUtc()!=null + && !run.getSupersededAtUtc().isAfter(SelfopSupport.utcNow().minusDays(Math.max(7,config.getSalesRetentionDays()))) + && scope.getPublishedRunId()!=null && !Objects.equals(runId,scope.getPublishedRunId()) + && !Objects.equals(runId,previous),"SALES_VERSION_PROTECTED"); + SelfopSupport.require(mapper.activeReferences(scope.getSourceAccount(),scope.getWarehouseCode(),runId)==0,"SALES_INPUT_REFERENCED"); + int size=Math.max(1,Math.min(500,config.getPersistenceBatchSize())); + return new Deleted(runId,mapper.deleteDaily(runId,size),mapper.deleteProfiles(runId,size)); + } + private void validateScope(SelfopSyncScope scope) { + SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()),"SALES_RETENTION_SCOPE_INVALID"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java new file mode 100644 index 000000000..c6ff45496 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** Quality precedes zero; denominator uses all 30 complete days. */ +public final class InventoryCoveragePolicy { + private InventoryCoveragePolicy() {} + public record Result(BigDecimal average, BigDecimal days, String status) {} + public static Result calculate(String inventoryQuality, BigDecimal available, BigDecimal sales30, + boolean salesTrusted, boolean complete30, boolean observationEligible, + boolean unitConfirmed) { + if (!"VALID".equals(inventoryQuality) || available == null) + return new Result(null, null, "INVENTORY_" + inventoryQuality); + if (available.signum() < 0) return new Result(null, null, "INVENTORY_INVALID"); + BigDecimal average = unitConfirmed && salesTrusted && complete30 && observationEligible && sales30 != null + ? sales30.divide(BigDecimal.valueOf(30), 12, RoundingMode.HALF_UP) : null; + if (available.signum() == 0) return new Result(average, BigDecimal.ZERO.setScale(1), "NO_AVAILABLE_INVENTORY"); + if (!unitConfirmed) return new Result(null, null, "UNIT_UNKNOWN_OR_CONFLICT"); + if (!salesTrusted) return new Result(null, null, "SALES_UNTRUSTED"); + if (!complete30) return new Result(null, null, "COVERAGE_INSUFFICIENT"); + if (!observationEligible) return new Result(null, null, "OBSERVATION_INSUFFICIENT"); + if (sales30 == null) return new Result(null, null, "SALES_UNKNOWN"); + if (sales30.signum() == 0) return new Result(average, null, "NO_OUTBOUND_30_DAYS"); + // Algebraically stock / (sales / 30), avoiding intermediate rounding. + return new Result(average, available.multiply(BigDecimal.valueOf(30)).divide(sales30, 1, RoundingMode.HALF_UP), "VALID"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java new file mode 100644 index 000000000..614f4643c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/** Daily quantities distinguish complete facts, observed facts and unknown history. */ +public final class SalesBucketPolicy { + private SalesBucketPolicy() {} + public record Input(LocalDate date, LocalDate anchorDate, ZoneId zone, + LocalDate completeDayFrom, LocalDate completeDayUntilExclusive, + Instant coveredFrom, Instant coveredUntil, Instant evidenceAt, + BigDecimal actualQuantity, Long actualOrderCount, boolean unitConfirmed, + boolean salesKnown, boolean rebuild, boolean stale) {} + public record Point(LocalDate date, BigDecimal outboundQuantity, BigDecimal observedQuantity, + Long orderCount, String dataStatus, List reasonCodes, + Instant provedFrom, Instant provedUntil) {} + public record Summary(BigDecimal outboundQuantity, BigDecimal observedQuantity, String dataStatus, + List reasonCodes, int completeDays, int partialDays, int unknownDays) {} + + public static Point evaluate(Input input) { + Objects.requireNonNull(input.date(), "date"); + Objects.requireNonNull(input.anchorDate(), "anchorDate"); + Objects.requireNonNull(input.zone(), "zone"); + if (input.date().isAfter(input.anchorDate())) throw new IllegalArgumentException("Future bucket"); + Instant start = input.date().atStartOfDay(input.zone()).toInstant(); + Instant end = input.date().plusDays(1).atStartOfDay(input.zone()).toInstant(); + Instant provedFrom = input.coveredFrom() == null ? null : later(start, input.coveredFrom()); + Instant provedUntil = input.coveredUntil() == null ? null : earlier(end, input.coveredUntil()); + boolean intersection = provedFrom != null && provedUntil != null && provedFrom.isBefore(provedUntil); + if (!intersection) { provedFrom = null; provedUntil = null; } + List reasons = new ArrayList<>(); + if (input.rebuild()) reasons.add("SALES_REBUILD_REQUIRED"); + if (input.stale()) reasons.add("SALES_STALE"); + if (!input.unitConfirmed()) { + reasons.add("UNIT_UNKNOWN_OR_CONFLICT"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + if (!input.salesKnown()) { + reasons.add("SALES_NOT_COVERED"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + boolean today = input.date().equals(input.anchorDate()); + boolean complete = !today && input.completeDayFrom() != null && input.completeDayUntilExclusive() != null + && !input.date().isBefore(input.completeDayFrom()) + && input.date().isBefore(input.completeDayUntilExclusive()) + && input.coveredFrom() != null && !input.coveredFrom().isAfter(start) + && input.coveredUntil() != null && !input.coveredUntil().isBefore(end); + boolean actual = input.actualQuantity() != null && input.actualQuantity().signum() >= 0; + if (input.actualQuantity() != null && !actual) { + reasons.add("SALES_QUANTITY_INVALID"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + if (complete && (actual || input.evidenceAt() != null && !input.evidenceAt().isAfter(start))) { + BigDecimal quantity = actual ? input.actualQuantity() : BigDecimal.ZERO; + return point(input, quantity, quantity, actual ? input.actualOrderCount() : 0L, + "COMPLETE", reasons, provedFrom, provedUntil); + } + if (today) reasons.add("TODAY_INCOMPLETE"); + else reasons.add("SALES_DAY_INCOMPLETE"); + if (actual) return point(input, null, input.actualQuantity(), input.actualOrderCount(), + "PARTIAL", reasons, provedFrom, provedUntil); + if (intersection && input.evidenceAt() != null && later(provedFrom, input.evidenceAt()).isBefore(provedUntil)) { + reasons.add("SKU_PARTIAL_OBSERVATION"); + return point(input, null, BigDecimal.ZERO, 0L, "PARTIAL", reasons, provedFrom, provedUntil); + } + reasons.add(input.evidenceAt() == null || !input.evidenceAt().isBefore(end) + ? "SKU_HISTORY_UNKNOWN" : "SALES_NOT_COVERED"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + + public static Summary aggregate(List points) { + Objects.requireNonNull(points, "points"); + int complete = 0, partial = 0, unknown = 0; + BigDecimal outbound = BigDecimal.ZERO, observed = BigDecimal.ZERO; + boolean hasObserved = false; + LinkedHashSet reasons = new LinkedHashSet<>(); + for (Point point : points) { + reasons.addAll(point.reasonCodes()); + switch (point.dataStatus()) { + case "COMPLETE" -> { complete++; outbound = outbound.add(point.outboundQuantity()); } + case "PARTIAL" -> partial++; + case "UNKNOWN" -> unknown++; + default -> throw new IllegalArgumentException("Unknown dataStatus"); + } + if (point.observedQuantity() != null) { observed = observed.add(point.observedQuantity()); hasObserved = true; } + } + boolean allComplete = !points.isEmpty() && complete == points.size(); + return new Summary(allComplete ? outbound : null, hasObserved ? observed : null, + allComplete ? "COMPLETE" : hasObserved ? "PARTIAL" : "UNKNOWN", + List.copyOf(reasons), complete, partial, unknown); + } + private static Point point(Input input, BigDecimal outbound, BigDecimal observed, Long count, + String status, List reasons, Instant from, Instant until) { + return new Point(input.date(), outbound, observed, count, status, List.copyOf(reasons), from, until); + } + private static Instant later(Instant a, Instant b) { return a.isAfter(b) ? a : b; } + private static Instant earlier(Instant a, Instant b) { return a.isBefore(b) ? a : b; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java new file mode 100644 index 000000000..6d39f9f78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.Objects; + +/** Natural-day query boundaries, resolved once for the fixed query version pair. */ +public final class SalesRangePolicy { + private SalesRangePolicy() {} + public enum Preset { TODAY, LAST_3_DAYS, THIS_WEEK, LAST_15_DAYS, THIS_MONTH, CUSTOM } + public record Range(LocalDate anchorDate, LocalDate startDate, LocalDate endDateExclusive, + int dayCount, Instant startAt, Instant endAtExclusive) {} + + public static Range resolve(String rangePreset, LocalDate startDate, LocalDate endDate, + String bucket, Instant queryAsOf, ZoneId zone, int maxQueryDays) { + Objects.requireNonNull(queryAsOf, "queryAsOf"); + Objects.requireNonNull(zone, "zone"); + if (maxQueryDays < 1 || maxQueryDays > 366) throw new IllegalArgumentException("maxQueryDays must be 1..366"); + if (!"DAY".equals(bucket)) throw new IllegalArgumentException("bucket must be DAY"); + Preset preset; + try { preset = Preset.valueOf(rangePreset); } + catch (RuntimeException ex) { throw new IllegalArgumentException("Unknown rangePreset", ex); } + LocalDate anchor = queryAsOf.atZone(zone).toLocalDate(); + LocalDate start; + LocalDate end = anchor; + if (preset == Preset.CUSTOM) { + if (startDate == null || endDate == null) throw new IllegalArgumentException("CUSTOM requires both dates"); + start = startDate; + end = endDate; + } else { + if (startDate != null || endDate != null) throw new IllegalArgumentException("Preset dates conflict with explicit dates"); + start = switch (preset) { + case TODAY -> anchor; + case LAST_3_DAYS -> anchor.minusDays(2); + case THIS_WEEK -> anchor.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + case LAST_15_DAYS -> anchor.minusDays(14); + case THIS_MONTH -> anchor.withDayOfMonth(1); + case CUSTOM -> throw new IllegalStateException("CUSTOM handled above"); + }; + } + long days = ChronoUnit.DAYS.between(start, end) + 1; + if (end.isAfter(anchor) || days < 1 || days > maxQueryDays) throw new IllegalArgumentException("Invalid query date range"); + LocalDate exclusive = end.plusDays(1); + return new Range(anchor, start, exclusive, (int) days, start.atStartOfDay(zone).toInstant(), + exclusive.atStartOfDay(zone).toInstant()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java new file mode 100644 index 000000000..46752ce75 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import java.time.Duration; +import java.time.Instant; + +/** Per-dispatch budget: cursors survive this object's lifetime in task manifests. */ +public final class SelfopBudget { + private final int limit; + private final Instant deadline; + private int requests; + public SelfopBudget(int limit,int seconds) { + if(limit<=0||seconds<=0)throw new IllegalArgumentException("SELFOP_BUDGET_INVALID"); + this.limit=limit;this.deadline=Instant.now().plusSeconds(seconds); + } + public boolean take() {if(requests>=limit||!Instant.now().isBefore(deadline))return false;requests++;return true;} + public int used(){return requests;} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java new file mode 100644 index 000000000..6023e7365 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopCommandService { + public record Command(String requestKey,String warehouseCode,String action,String taskNo, + LocalDateTime sourceFrom,LocalDateTime sourceUntil,String customerCode,String deliveryNo,String reason) { } + private final SelfopTaskService taskService; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopAnalyticsProperties config; + private final SelfopSyncTaskMapper tasks; + public SelfopCommandService(SelfopTaskService taskService,SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks){ + this.taskService=taskService;this.scopes=scopes;this.accounts=accounts;this.config=config;this.tasks=tasks; + } + @Transactional + public SelfopSyncTask request(Command c,String operator){ + identity(c.requestKey(),100);identity(c.warehouseCode(),64);require(config.getWarehouses().containsKey(c.warehouseCode()),"SELFOP_WAREHOUSE_UNCONFIGURED"); + taskService.initialize(); + if("RETRY".equals(c.action())){identity(c.taskNo(),64);identity(c.reason(),500); + var original=tasks.selectOne(new QueryWrapper().eq("task_no",c.taskNo()).eq("source_account",config.getSourceAccount()));require(original!=null,"SELFOP_TASK_SCOPE_MISMATCH"); + require("WAREHOUSE_SYNC".equals(original.getTaskRole())&&c.warehouseCode().equals(original.getWarehouseCode()),"SELFOP_TASK_SCOPE_MISMATCH"); + require(!"WAITING_SOURCE".equals(original.getStatus()),"SELFOP_RETRY_WAITING_SOURCE"); + return taskService.retry(original.getId(),c.requestKey(),operator,c.reason());} + require(Set.of("INVENTORY","BACKFILL","RECHECK","REBUILD","VALIDATION").contains(c.action()),"SELFOP_COMMAND_INVALID"); + String purpose=SelfopCollectionPolicy.configuredPurpose(config); + require(("VALIDATION".equals(purpose))==("VALIDATION".equals(c.action())),"SELFOP_VALIDATION_ENTRY_REQUIRED"); + var scope=scopes.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("warehouse_code",c.warehouseCode()) + .eq("data_family","INVENTORY".equals(c.action())?"INVENTORY":"OUTBOUND")); + var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setRequestedBy(operator);task.setRequestKey("manual:"+c.requestKey()); + task.setExecutionPurpose(purpose); + task.setTaskType("INVENTORY".equals(c.action())?"BACKFILL":c.action());task.setScopeHash(scope.getScopeHash()); + if(Set.of("INVENTORY","REBUILD").contains(c.action())){ + task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(scope.getId());task.setWarehouseCode(c.warehouseCode());task.setQueryScopeJson(scope.getScopeJson()); + }else{ + var account=accounts.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount())); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(account.getId()); + if("VALIDATION".equals(c.action())){ + require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil())&&!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_QUERY_WINDOW_INVALID"); + task.setTaskType("VALIDATION");task.setQueryScopeJson(json(Map.of("startTime",c.sourceFrom().toString(),"endTime",c.sourceUntil().toString(),"warehouses",config.getWarehouses().keySet()))); + }else if("RECHECK".equals(c.action())){identity(c.customerCode(),64);identity(c.deliveryNo(),128); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null,null,c.deliveryNo(),c.customerCode(),null))); + }else{ + require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil()),"SELFOP_QUERY_WINDOW_INVALID"); + require(!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_SLICE_MAX_ONE_DAY"); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(c.sourceFrom().toString(),c.sourceUntil().toString(),null,null,null))); + if(config.getSourceQueryTimezone()!=null){ + ZoneId source=ZoneId.of(config.getSourceQueryTimezone()); + require(source.getRules().getValidOffsets(c.sourceFrom()).size()==1&&source.getRules().getValidOffsets(c.sourceUntil()).size()==1,"SELFOP_SOURCE_TIME_AMBIGUOUS"); + task.setQueryFromUtc(c.sourceFrom().atZone(source).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + task.setQueryUntilUtc(c.sourceUntil().atZone(source).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + } + } + } + task.setRequestHash(hash(Arrays.asList(c,task.getScopeHash())));return taskService.create(task); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java new file mode 100644 index 000000000..f2aec1186 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -0,0 +1,235 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException; +import com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundPolicy; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopDiscoveryService { + public record Query(String startTime,String endTime,String deliveryNo,String customerCode,Long parentTaskId) { } + private final LingxingAnalyticsClient client; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopSyncStageMapper stages; + private final SelfopOutboundOrderMapper orders; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopTaskService taskService; + private final SelfopLeaseService leases; + private final SelfopAnalyticsProperties config; + private final TransactionTemplate tx; + public SelfopDiscoveryService(LingxingAnalyticsClient client,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs, + SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopSyncScopeMapper scopes, + SelfopOutboundDiscoveryScopeMapper accounts,SelfopTaskService taskService,SelfopLeaseService leases, + SelfopAnalyticsProperties config,TransactionTemplate tx) { + this.client=client;this.tasks=tasks;this.runs=runs;this.stages=stages;this.orders=orders;this.scopes=scopes; + this.accounts=accounts;this.taskService=taskService;this.leases=leases;this.config=config;this.tx=tx; + } + /** Network requests are outside tx; every accepted cursor/stage write checks the account fence again. */ + public void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) { + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run); + var a=accounts.selectById(task.getDiscoveryScopeId());var budget=new SelfopBudget(a.getRequestBudget(),a.getTimeBudgetSeconds()); + collect(task,run,lease,budget); + } + private void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget) { + Query query=read(task.getQueryScopeJson(),Query.class); + var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + if(query.deliveryNo()!=null) {if(!collectLocated(task,run,lease,budget,query,manifest))return;} + else { + while(!manifest.isDirectoryComplete()) { + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;} + leases.renew(lease); + var page=client.discover(SelfopRequestContext.forTask(task,run,config),LocalDateTime.parse(query.startTime()),LocalDateTime.parse(query.endTime()),manifest.getNextPage(),config.getPageSize()); + require(page.current()==manifest.getNextPage()&&page.size()==config.getPageSize(),"SELFOP_PAGE_MISMATCH"); + require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING"); + if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());} + require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); + tx.executeWithoutResult(s->{leases.verify(lease); + for(var summary:page.records()) { + identity(summary.customerCode(),64);identity(summary.deliveryNo(),128);identity(summary.warehouseCode(),64); + String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));String content=hash(summary); + if(manifest.getScan()==1){ + require(!manifest.getFirstScan().containsKey(key),"SELFOP_DIRECTORY_DUPLICATE");manifest.getFirstScan().put(key,content); + var payload=new SelfopPayloads.Order(task.getSourceAccount(),summary.customerCode(),summary.warehouseCode(),summary.deliveryNo(),summary.sourceNo(),summary.sourceStatus(),summary.sourceCreatedTime(),summary.sourceOutboundTime(),null); + var stage=stage(task,run,payload,manifest.getNextPage(),null,"PENDING"); + var prior=stages.selectOne(new QueryWrapper().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key)); + if(prior==null)stages.insert(stage);else if("PENDING".equals(prior.getValidationStatus())){ + stage.setId(prior.getId());stage.setRunId(prior.getRunId());stage.setCreatedAt(prior.getCreatedAt());stages.updateById(stage); + }else stage=prior; + var entry=new SelfopRoutingManifest.Entry();entry.setCustomerCode(summary.customerCode());entry.setDeliveryNo(summary.deliveryNo()); + entry.setWarehouseCode(summary.warehouseCode());entry.setDirectoryStageId(stage.getId());manifest.getObjects().put(key,entry); + }else{ + require(Objects.equals(manifest.getFirstScan().get(key),content),"SELFOP_DIRECTORY_UNSTABLE"); + require(!manifest.getObjects().containsKey("seen:"+key),"SELFOP_DIRECTORY_DUPLICATE"); + manifest.getObjects().put("seen:"+key,new SelfopRoutingManifest.Entry()); + } + } + manifest.setNextPage(manifest.getNextPage()+1); + if(manifest.getPages()==0||manifest.getNextPage()>manifest.getPages()) { + long count=manifest.getScan()==1?manifest.getFirstScan().size():manifest.getObjects().keySet().stream().filter(k->k.startsWith("seen:")).count(); + require(count==manifest.getTotal(),"SELFOP_DIRECTORY_COUNT_MISMATCH"); + if(manifest.getScan()==1){manifest.setScan(2);manifest.setNextPage(1);} + else{manifest.getObjects().keySet().removeIf(k->k.startsWith("seen:"));manifest.setDirectoryComplete(true);} + } + saveManifest(task,manifest); + }); + } + } + route(task,manifest,lease); + for(var entry:manifest.getObjects().entrySet()) { + var ref=entry.getValue(); + if(ref.getRefreshTaskId()!=null){ + var refresh=tasks.selectById(ref.getRefreshTaskId());require(refresh!=null,"SELFOP_REFRESH_TASK_MISSING"); + if(!"SUCCEEDED".equals(refresh.getStatus())){ + var refreshRun=taskService.begin(refresh.getId(),lease);refresh=tasks.selectById(refresh.getId());collect(refresh,refreshRun,lease,budget); + refresh=tasks.selectById(refresh.getId()); + if(!"SUCCEEDED".equals(refresh.getStatus())){taskService.yield(task.getId(),run.getId(),lease);return;} + } + var refreshed=read(refresh.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(entry.getKey()); + require(refreshed!=null&&refreshed.getEffectiveStageId()!=null,"SELFOP_REFRESH_REFERENCE_MISSING"); + ref.setEffectiveStageId(refreshed.getEffectiveStageId());ref.setWarehouseCode(refreshed.getWarehouseCode()); + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + } + var latest=stages.selectOne(new QueryWrapper().eq("object_type","OUTBOUND_ORDER") + .eq("object_key_hash",entry.getKey()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1")); + var effective=ref.getEffectiveStageId()==null?null:stages.selectById(ref.getEffectiveStageId()); + boolean newer=latest!=null&&(effective==null?latest.getObservationSequence()>task.getObservationBaseline():latest.getObservationSequence()>effective.getObservationSequence()); + if(effective!=null&&!newer) continue; + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;} + leases.renew(lease); + SelfopSyncTask fetchTask=task;SelfopSyncRun fetchRun=run; + if(newer){ + var recheck=new SelfopSyncTask();recheck.setSourceAccount(task.getSourceAccount());recheck.setTaskRole("ACCOUNT_DISCOVERY"); + recheck.setExecutionPurpose(task.getExecutionPurpose()); + recheck.setDiscoveryScopeId(task.getDiscoveryScopeId());recheck.setTaskType("RECHECK");recheck.setScopeHash(task.getScopeHash()); + recheck.setRequestKey("refresh:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+latest.getObservationSequence()); + recheck.setQueryScopeJson(json(new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId())));recheck.setRequestHash(hash(recheck.getQueryScopeJson())); + fetchTask=taskService.create(recheck);ref.setRefreshTaskId(fetchTask.getId());ref.setNewestSeenSequence(latest.getObservationSequence()); + fetchRun=taskService.begin(fetchTask.getId(),lease); + fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); + } + long observation=leases.nextObservation(lease); + SelfopPayloads.Order detail; + try {detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());} + catch(AnalyticsClientException staleIdentity){ + if(!Set.of("DETAIL_IDENTITY_MISMATCH","INTEGRATION_DATA_MISSING","LINGXING_WMS_SOURCE_REJECTED","LINGXING_WMS_INVALID_RESPONSE").contains(staleIdentity.code())||staleIdentity.retryable())throw staleIdentity; + var relocate=new SelfopSyncTask();relocate.setSourceAccount(task.getSourceAccount());relocate.setTaskRole("ACCOUNT_DISCOVERY");relocate.setDiscoveryScopeId(task.getDiscoveryScopeId()); + relocate.setExecutionPurpose(task.getExecutionPurpose()); + relocate.setTaskType("RECHECK");relocate.setScopeHash(task.getScopeHash());relocate.setRequestKey("relocate:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+observation); + var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson())); + fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId()); + fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + var located=new SelfopRoutingManifest(); + if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});taskService.yield(task.getId(),run.getId(),lease);return;} + ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode()); + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;} + observation=leases.nextObservation(lease);detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo()); + } + require(Objects.equals(detail.sourceAccount(),task.getSourceAccount())&&Objects.equals(detail.customerCode(),ref.getCustomerCode()) + &&Objects.equals(detail.deliveryNo(),ref.getDeliveryNo()),"SELFOP_DETAIL_IDENTITY_MISMATCH"); + require(detail.items()!=null&&detail.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID"); + identity(detail.warehouseCode(),64); + var finalFetchTask=fetchTask;var finalFetchRun=fetchRun;var acceptedDetail=detail;long acceptedObservation=observation; + tx.executeWithoutResult(s->{leases.verify(lease); + var existing=stages.selectOne(new QueryWrapper().eq("stage_batch_id",finalFetchTask.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",entry.getKey())); + SelfopSyncStage full; + if(existing!=null&&"VALID".equals(existing.getValidationStatus())){ + require(existing.getContentHash().equals(hash(acceptedDetail)),"SELFOP_VALID_STAGE_IMMUTABLE");full=existing; + }else{ + full=stage(finalFetchTask,finalFetchRun,acceptedDetail,existing==null?1:existing.getFirstPageNo(),acceptedObservation,"VALID"); + if(existing!=null){full.setId(existing.getId());full.setRunId(existing.getRunId());full.setCreatedAt(existing.getCreatedAt());stages.updateById(full);}else stages.insert(full); + } + ref.setEffectiveStageId(full.getId());ref.setWarehouseCode(acceptedDetail.warehouseCode());saveManifest(task,manifest); + if(!finalFetchTask.getId().equals(task.getId())){var refreshManifest=new SelfopRoutingManifest();refreshManifest.setDirectoryComplete(true);refreshManifest.setPages(1);refreshManifest.setTotal(1L); + var refreshRef=new SelfopRoutingManifest.Entry();refreshRef.setCustomerCode(ref.getCustomerCode());refreshRef.setDeliveryNo(ref.getDeliveryNo());refreshRef.setWarehouseCode(ref.getWarehouseCode());refreshRef.setEffectiveStageId(full.getId()); + refreshManifest.getObjects().put(entry.getKey(),refreshRef);finalFetchTask.setRoutingManifestJson(json(refreshManifest)); + SelfopProgressProjection.freezeRun(finalFetchTask,finalFetchRun);finalFetchRun.setStatus("ROUTED");finalFetchRun.setFetchFinishedAtUtc(utcNow());finalFetchRun.setFinishedAtUtc(utcNow());finalFetchRun.setUpdatedAt(utcNow());runs.updateById(finalFetchRun); + finalFetchTask.setStatus("SUCCEEDED");finalFetchTask.setUpdatedAt(utcNow());tasks.updateById(finalFetchTask);} + }); + route(task,manifest,lease); + } + tx.executeWithoutResult(s->{leases.verify(lease);var now=utcNow(); + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopProgressProjection.freezeRun(task,run); + run.setStatus("ROUTED");run.setCoverageStatus("COMPLETE");run.setPageCount(manifest.getPages()==null?0:manifest.getPages()*2); + run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getObjects().size());run.setFetchFinishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task); + var account=accounts.selectById(task.getDiscoveryScopeId()); + if(task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&& + (account.getCatalogUntilUtc()==null||!task.getQueryFromUtc().isAfter(account.getCatalogUntilUtc()))) { + account.setCatalogFromUtc(account.getCatalogFromUtc()==null?task.getQueryFromUtc():account.getCatalogFromUtc()); + account.setCatalogUntilUtc(account.getCatalogUntilUtc()==null||task.getQueryUntilUtc().isAfter(account.getCatalogUntilUtc())?task.getQueryUntilUtc():account.getCatalogUntilUtc()); + account.setUpdatedAt(now);accounts.updateById(account);} + }); + } + private boolean collectLocated(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget,Query query,SelfopRoutingManifest manifest) { + if(manifest.isDirectoryComplete())return true; + // Identity relocation consumes the full paginated result before trusting one match. + while(!manifest.isDirectoryComplete()){ + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return false;}leases.renew(lease); + var page=client.locate(SelfopRequestContext.forTask(task,run,config),query.deliveryNo(),manifest.getNextPage(),config.getPageSize()); + require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING"); + if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());} + require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); + for(var summary:page.records())if(query.deliveryNo().equals(summary.deliveryNo())&&query.customerCode().equals(summary.customerCode())){ + String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));require(!manifest.getObjects().containsKey(key),"SELFOP_LOCATE_AMBIGUOUS"); + var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode());manifest.getObjects().put(key,e);} + manifest.setNextPage(manifest.getNextPage()+1);if(page.pages()==0||manifest.getNextPage()>page.pages()){ + require(manifest.getObjects().size()==1,"SELFOP_LOCATE_UNRESOLVED");manifest.setDirectoryComplete(true);} + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + } + return true; + } + private SelfopSyncStage stage(SelfopSyncTask task,SelfopSyncRun run,SelfopPayloads.Order payload,int page,Long observation,String status){ + var s=new SelfopSyncStage();s.setId(IdWorker.getId());s.setRunId(run.getId());s.setStageBatchId(task.getId());s.setLastFetchRunId(run.getId()); + s.setObservationSequence(observation);s.setWarehouseCode(payload.warehouseCode());s.setObjectType("OUTBOUND_ORDER"); + s.setObjectKeyHash(hash(List.of(payload.sourceAccount(),payload.customerCode(),payload.deliveryNo())));s.setFirstPageNo(page);s.setObservedAtUtc(utcNow()); + s.setContentHash(hash(payload));s.setPayloadJson(json(payload));s.setValidationStatus(status);s.setCreatedAt(utcNow());s.setUpdatedAt(utcNow());return s; + } + private void saveManifest(SelfopSyncTask task,SelfopRoutingManifest manifest){task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow()); + var active=runs.selectOne(new QueryWrapper().eq("task_id",task.getId()).eq("status","FETCHING").orderByDesc("attempt_no").last("LIMIT 1")); + if(active!=null){SelfopProgressProjection.applyCheckpoint(task,active,manifest.isDirectoryComplete()?"DETAIL":"DIRECTORY",manifest.isDirectoryComplete()?"ORDER":"PAGE", + manifest.isDirectoryComplete()?manifest.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count():Math.max(0,manifest.getNextPage()-1), + manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());runs.updateById(active);} + tasks.updateById(task);} + private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease){ + // Refresh commands only replace a parent manifest reference; never create an independent + // fact writer that could publish around that parent's half-applied barrier. + if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return; + tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return; + for(var scope:scopes.selectList(new QueryWrapper().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){ + var own=new SelfopRoutingManifest();own.setDirectoryComplete(true);own.setTotal(0L);own.setPages(manifest.getPages()); + for(var e:manifest.getObjects().entrySet()){ + var ref=e.getValue();var previous=orders.selectOne(new QueryWrapper().eq("source_account",parent.getSourceAccount()).eq("customer_code",ref.getCustomerCode()).eq("delivery_no",ref.getDeliveryNo())); + if(scope.getWarehouseCode().equals(ref.getWarehouseCode())||(previous!=null&&scope.getWarehouseCode().equals(previous.getWarehouseCode()))){ + boolean belongs=true; + if(ref.getEffectiveStageId()!=null){var payload=read(stages.selectById(ref.getEffectiveStageId()).getPayloadJson(),SelfopPayloads.Order.class); + belongs=OutboundPolicy.belongsToSlice(payload,scope,parent.getQueryFromUtc(),parent.getQueryUntilUtc(),previous!=null,parent.getTaskType());} + if(belongs)own.getObjects().put(e.getKey(),ref); + } + } + own.setTotal((long)own.getObjects().size()); + boolean ready=scope.getSourceTimezone()!=null&&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())) + &&own.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null); + var child=new SelfopSyncTask();child.setSourceAccount(parent.getSourceAccount());child.setTaskRole("WAREHOUSE_SYNC");child.setScopeId(scope.getId());child.setWarehouseCode(scope.getWarehouseCode()); + child.setExecutionPurpose(parent.getExecutionPurpose()); + child.setUpstreamTaskId(parent.getId());child.setDiscoverySequence(parent.getDiscoverySequence());child.setObservationBaseline(parent.getObservationBaseline()); + child.setRequestKey("route:"+parent.getId()+":"+scope.getWarehouseCode());child.setTaskType(parent.getTaskType());child.setQueryFromUtc(parent.getQueryFromUtc());child.setQueryUntilUtc(parent.getQueryUntilUtc()); + child.setQueryScopeJson(scope.getScopeJson());child.setScopeHash(scope.getScopeHash());child.setRequestHash(hash(List.of(parent.getId(),scope.getScopeHash())));child.setStatus(ready?"READY":"WAITING_SOURCE");child.setApplyState("NOT_STARTED"); + var saved=taskService.create(child); + if(!"SUCCEEDED".equals(saved.getStatus())&& !Set.of("APPLYING","APPLIED","BLOCKED").contains(saved.getApplyState())){ + saved.setRoutingManifestJson(json(own));saved.setStatus(ready?"READY":"WAITING_SOURCE");saved.setUpdatedAt(utcNow());tasks.updateById(saved);} + } + }); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java new file mode 100644 index 000000000..d6038f892 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.outbound.*; +import com.baiyue.lastleg.selfoperated.analytics.inventory.InventorySnapshotService; +import org.springframework.stereotype.Service; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopExecutionService { + private final SelfopAnalyticsProperties config; + private final SelfopTaskService taskService; + private final SelfopLeaseService leases; + private final SelfopDiscoveryService discovery; + private final SelfopInventoryCollector stock; + private final InventorySnapshotService snapshots; + private final OutboundFactService facts; + private final OutboundCandidateBuilder candidates; + private final SelfopSalesPublisher publisher; + private final SelfopSyncScopeMapper scopes; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncStageMapper stages; + private final SelfopOutboundOrderMapper orders; + private final SelfopReferenceRecovery recovery; + private final SelfopValidationRunner validation; + private final SelfopSyncRunMapper runs; + public SelfopExecutionService(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopLeaseService leases, + SelfopDiscoveryService discovery,SelfopInventoryCollector stock,InventorySnapshotService snapshots,OutboundFactService facts, + OutboundCandidateBuilder candidates,SelfopSalesPublisher publisher,SelfopSyncScopeMapper scopes,SelfopSyncTaskMapper tasks, + SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopReferenceRecovery recovery,SelfopValidationRunner validation,SelfopSyncRunMapper runs){ + this.config=config;this.taskService=taskService;this.leases=leases;this.discovery=discovery;this.stock=stock;this.snapshots=snapshots; + this.facts=facts;this.candidates=candidates;this.publisher=publisher;this.scopes=scopes;this.tasks=tasks;this.stages=stages;this.orders=orders;this.recovery=recovery;this.validation=validation;this.runs=runs; + } + public void execute(long taskId){ + var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose()); + if("SUCCEEDED".equals(task.getStatus())||"DEAD".equals(task.getStatus()))return; + String owner=UUID.randomUUID().toString();Map held=new LinkedHashMap<>();SelfopLeaseService.Lease primary; + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){primary=leases.acquireDiscovery(task.getDiscoveryScopeId(),owner);held.put(primary.scopeId(),primary);} + else { + var ids=new TreeSet();ids.add(task.getScopeId()); + if("OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily())){ + var warehouses=new TreeSet();warehouses.add(task.getWarehouseCode()); + if(task.getAffectedWarehouseCodesJson()!=null)warehouses.addAll(read(task.getAffectedWarehouseCodesJson(),List.class)); + if(task.getRoutingManifestJson()!=null){var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + for(var ref:manifest.getObjects().values())if(ref.getEffectiveStageId()!=null){var stage=stages.selectById(ref.getEffectiveStageId());var p=read(stage.getPayloadJson(),SelfopPayloads.Order.class); + warehouses.add(p.warehouseCode());var old=orders.selectOne(new QueryWrapper().eq("source_account",p.sourceAccount()).eq("customer_code",p.customerCode()).eq("delivery_no",p.deliveryNo())); + var latest=stages.selectOne(new QueryWrapper().eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",stage.getObjectKeyHash()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1")); + if(latest!=null)warehouses.add(latest.getWarehouseCode()); + if(old!=null)warehouses.add(old.getWarehouseCode());}} + for(String w:warehouses){var s=scopes.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount()).eq("warehouse_code",w).eq("data_family","OUTBOUND")); + require(s!=null,"SELFOP_WAREHOUSE_UNCONFIGURED");ids.add(s.getId());} + } + held.putAll(leases.acquireScopes(ids,owner));primary=held.get(task.getScopeId()); + } + SelfopSyncRun run=null; + try{ + run=taskService.begin(taskId,primary); + task=tasks.selectById(taskId); + if("VALIDATION".equals(task.getExecutionPurpose())){validation.run(task,run,primary);return;} + if(!primary.account()&&"OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily()))recovery.refresh(task,held); + if(primary.account())discovery.collect(task,run,primary); + else if("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())){ + if(stock.collect(task,run,primary)){snapshots.build(task.getScopeId(),run.getId(),owner,primary.fence());snapshots.publish(task.getScopeId(),run.getId(),owner,primary.fence());} + }else{ + Map outbound=new TreeMap<>();for(var lease:held.values())outbound.put(scopes.selectById(lease.scopeId()).getWarehouseCode(),new OutboundFactService.Lease(owner,lease.fence())); + if(!"REBUILD".equals(task.getTaskType())){ + var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);var ids=manifest.getObjects().values().stream().map(SelfopRoutingManifest.Entry::getEffectiveStageId).distinct().toList(); + require(ids.stream().noneMatch(Objects::isNull),"SELFOP_DETAILS_INCOMPLETE"); + if(!"APPLIED".equals(task.getApplyState())){ + int batch=Math.max(1,Math.min(100,config.getApplyBatchSize()));for(int i=0;i().eq("scope_id",task.getScopeId()).isNotNull("build_started_at_utc").orderByDesc("build_started_at_utc").last("LIMIT 1")); + if(previousBuild!=null&&!OutboundCandidateBuilder.regularBuildDue(task,previousBuild.getBuildStartedAtUtc(),utcNow())){ + taskService.waitForBudget(taskId,run.getId(),previousBuild.getBuildStartedAtUtc().plusMinutes(30),primary);return; + } + candidates.build(task.getScopeId(),run.getId(),owner,primary.fence()); + if(config.isSalesPublicationEnabled())publisher.publish(task.getScopeId(),run.getId(),owner,primary.fence()); + else taskService.candidateFinished(taskId,run.getId(),primary); + } + }catch(RuntimeException e){ + if(run!=null&&"BUSINESS".equals(task.getExecutionPurpose())&& e instanceof com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException q && q.waitUntilUtc()!=null){ + try{taskService.waitForBudget(taskId,run.getId(),q.waitUntilUtc(),primary);}catch(IllegalStateException lostFence){ }return; + } + if(run!=null){String code=e instanceof AnalyticsClientException c?c.code():safeCode(e.getMessage()); + boolean retryable=!(e instanceof AnalyticsClientException c)||c.retryable(); + try{ + if("VALIDATION".equals(task.getExecutionPurpose()))validation.fail(task,run,primary,code); + else taskService.fail(taskId,run.getId(),code,retryable,primary); + }catch(IllegalStateException lostFence){/* New lease owner decides recovery. */}} + }finally{for(var lease:held.values())leases.release(lease);} + } + private static String safeCode(String message){return message!=null&&message.matches("[A-Z][A-Z0-9_]{0,63}")?message:"SELFOP_EXECUTION_FAILED";} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java new file mode 100644 index 000000000..bd8a04975 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopInventoryCollector { + private final LingxingAnalyticsClient client; + private final SelfopSyncStageMapper stages; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopLeaseService leases; + private final SelfopTaskService taskService; + private final SelfopAnalyticsProperties config; + private final TransactionTemplate tx; + public SelfopInventoryCollector(LingxingAnalyticsClient client,SelfopSyncStageMapper stages,SelfopSyncTaskMapper tasks, + SelfopSyncRunMapper runs,SelfopLeaseService leases,SelfopTaskService taskService,SelfopAnalyticsProperties config,TransactionTemplate tx){ + this.client=client;this.stages=stages;this.tasks=tasks;this.runs=runs;this.leases=leases;this.taskService=taskService;this.config=config;this.tx=tx; + } + public boolean collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease){ + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run); + var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + var budget=new SelfopBudget(config.getRequestBudget(),config.getTimeBudgetSeconds()); + while(!manifest.isDirectoryComplete()){ + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return false;} + leases.renew(lease);var page=client.inventory(SelfopRequestContext.forTask(task,run,config),task.getWarehouseCode(),manifest.getNextPage(),config.getPageSize()); + require(page.current()==manifest.getNextPage()&&page.size()==config.getPageSize(),"SELFOP_PAGE_MISMATCH"); + require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING"); + if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());} + require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); + tx.executeWithoutResult(s->{leases.verify(lease); + for(var row:page.records()){ + require(task.getSourceAccount().equals(row.sourceAccount())&&task.getWarehouseCode().equals(row.warehouseCode()),"SELFOP_STOCK_SCOPE_LEAK"); + identity(row.customerCode(),64);identity(row.skuCode(),128);require(row.productQuality()!=null,"SELFOP_STOCK_IDENTITY_MISSING"); + String key=hash(List.of(row.sourceAccount(),row.customerCode(),row.warehouseCode(),row.skuCode(),row.productQuality())); + if(manifest.getScan()==1){require(!manifest.getFirstScan().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getFirstScan().put(key,hash(row)); + var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(run.getId());stage.setStageBatchId(run.getId());stage.setLastFetchRunId(run.getId()); + stage.setWarehouseCode(row.warehouseCode());stage.setObjectType("PRODUCT_STOCK");stage.setObjectKeyHash(key);stage.setFirstPageNo(manifest.getNextPage()); + stage.setObservedAtUtc(utcNow());stage.setContentHash(hash(row));stage.setPayloadJson(json(row));stage.setValidationStatus("VALID");stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage); + }else{require(Objects.equals(manifest.getFirstScan().get(key),hash(row)),"SELFOP_STOCK_SCAN_UNSTABLE"); + require(!manifest.getObjects().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getObjects().put(key,new SelfopRoutingManifest.Entry());} + } + manifest.setNextPage(manifest.getNextPage()+1); + if(manifest.getPages()==0||manifest.getNextPage()>manifest.getPages()){ + require((manifest.getScan()==1?manifest.getFirstScan().size():manifest.getObjects().size())==manifest.getTotal(),"SELFOP_STOCK_COUNT_MISMATCH"); + if(manifest.getScan()==1){manifest.setScan(2);manifest.setNextPage(1);}else manifest.setDirectoryComplete(true); + } + task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow());tasks.updateById(task); + SelfopProgressProjection.applyCheckpoint(task,run,"INVENTORY","PAGE",Math.max(0,manifest.getNextPage()-1),manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());tasks.updateById(task);runs.updateById(run); + }); + } + tx.executeWithoutResult(s->{leases.verify(lease);run.setStatus("VALIDATED");run.setLastPhase("VALIDATED");run.setCoverageStatus("COMPLETE"); + run.setPageCount(Math.max(1,manifest.getPages()));run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getFirstScan().size()); + run.setFetchFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); + });return true; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java new file mode 100644 index 000000000..e019daf45 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopLeaseService { + private final SelfopCoordinationMapper locks; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper discoveries; + private final SelfopAnalyticsProperties config; + public SelfopLeaseService(SelfopCoordinationMapper locks, SelfopSyncScopeMapper scopes, + SelfopOutboundDiscoveryScopeMapper discoveries, SelfopAnalyticsProperties config) { + this.locks=locks; this.scopes=scopes; this.discoveries=discoveries; this.config=config; + } + public record Lease(long scopeId, String owner, long fence, boolean account) { } + @Transactional + public Lease acquireDiscovery(long id, String owner) { + var row=locks.lockDiscovery(id); require(row!=null,"SELFOP_SCOPE_MISSING"); + var now=utcNow(); require(available(row.getLeaseOwner(),row.getLeaseUntilUtc(),now),"SELFOP_LEASE_BUSY"); + long fence=row.getFenceToken()+1; row.setLeaseOwner(owner); row.setFenceToken(fence); + row.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); row.setUpdatedAt(now); + discoveries.updateById(row); return new Lease(id,owner,fence,true); + } + /** Atomic all-or-none acquisition; never hold one warehouse while waiting for another. */ + @Transactional + public Map acquireScopes(Collection ids, String owner) { + var rows=ids.stream().distinct().map(scopes::selectById) + .sorted(Comparator.comparing(SelfopSyncScope::getSourceAccount) + .thenComparing(SelfopSyncScope::getWarehouseCode).thenComparing(SelfopSyncScope::getDataFamily)).toList(); + Map result=new LinkedHashMap<>(); var now=utcNow(); + for(var candidate:rows){ + var row=locks.lockScope(candidate.getId()); + require(available(row.getLeaseOwner(),row.getLeaseUntilUtc(),now),"SELFOP_LEASE_BUSY"); + long fence=row.getFenceToken()+1; row.setLeaseOwner(owner); row.setFenceToken(fence); + row.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); row.setUpdatedAt(now); + scopes.updateById(row); result.put(row.getId(),new Lease(row.getId(),owner,fence,false)); + } + return result; + } + @Transactional + public void renew(Lease lease) { + var now=utcNow(); + if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId()); check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),now); + s.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); s.setUpdatedAt(now); discoveries.updateById(s); + } else {var s=locks.lockScope(lease.scopeId()); check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),now); + s.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); s.setUpdatedAt(now); scopes.updateById(s);} + locks.heartbeat(lease.scopeId(),lease.account(),lease.fence(),now); + } + @Transactional + public void release(Lease lease) { + if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId()); + if(Objects.equals(s.getLeaseOwner(),lease.owner()) && s.getFenceToken()==lease.fence()) { + s.setLeaseOwner(null); s.setLeaseUntilUtc(null); s.setUpdatedAt(utcNow()); + discoveries.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",s.getId()).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",utcNow())); } + } else {var s=locks.lockScope(lease.scopeId()); + if(Objects.equals(s.getLeaseOwner(),lease.owner()) && s.getFenceToken()==lease.fence()) + scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",s.getId()).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",utcNow())); } + } + @Transactional + public long nextObservation(Lease lease) { + require(lease.account(),"SELFOP_ACCOUNT_LEASE_REQUIRED"); var s=locks.lockDiscovery(lease.scopeId()); + check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow()); + long seq=s.getNextObservationSequence(); s.setNextObservationSequence(seq+1); s.setUpdatedAt(utcNow()); + discoveries.updateById(s); return seq; + } + public void verify(Lease lease) { + if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId());check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow());} + else {var s=locks.lockScope(lease.scopeId());check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow());} + } + static boolean available(String owner, LocalDateTime until, LocalDateTime now) {return owner==null || until==null || !until.isAfter(now);} + public static void check(Lease lease,String owner,Long fence,LocalDateTime until,LocalDateTime now) { + require(Objects.equals(owner,lease.owner()) && fence!=null && fence==lease.fence() && until!=null && until.isAfter(now),"SELFOP_FENCE_REJECTED"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java new file mode 100644 index 000000000..79d074e3f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Small, durable checkpoint projection. Counts describe committed work, never HTTP attempts. */ +@Service +public class SelfopProgressService { + private static final Set PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY", + "VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED"); + private final SelfopCoordinationMapper locks; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopLeaseService leases; + public SelfopProgressService(SelfopCoordinationMapper locks, SelfopSyncTaskMapper tasks, + SelfopSyncRunMapper runs, SelfopLeaseService leases) { + this.locks=locks; this.tasks=tasks; this.runs=runs; this.leases=leases; + } + + /** Creation-only waiting evidence; no execution or observation is inferred. */ + @Transactional + public void initializeWaiting(long taskId, String phase) { + require(Set.of("WAITING_SOURCE", "WAITING").contains(phase), "SELFOP_PROGRESS_PHASE_INVALID"); + var task=locks.lockTask(taskId); require(task!=null,"SELFOP_TASK_MISSING"); + require(task.getAttemptCount()==null || task.getAttemptCount()==0,"SELFOP_PROGRESS_ALREADY_STARTED"); + if(task.getProgressJson()!=null)return; + var p=new LinkedHashMap(); p.put("attemptNo",0); p.put("phase",phase); + p.put("progressScope",scope(task)); p.put("stageStartedAt",instant(utcNow())); + p.put("unit","NONE"); p.put("completed",0); p.put("total",null); + p.put("scanRound",0);p.put("totalKnown",false);p.put("countBasis","TASK_CUMULATIVE"); + p.put("sliceFrom",instant(task.getQueryFromUtc()));p.put("sliceUntil",instant(task.getQueryUntilUtc())); + p.put("stageFinishedAt",null);p.put("stages",new ArrayList<>()); persist(task,p); + } + + /** Caller invokes after the corresponding manifest/stage/merge checkpoint in the same transaction. */ + @Transactional + public void checkpoint(long taskId, long runId, SelfopLeaseService.Lease lease, String phase, + String unit, long completed, Long total, int scan) { + require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID"); + require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID"); + require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID"); + leases.verify(lease); + var task=locks.lockTask(taskId); var run=locks.lockRun(runId); check(task,run,lease,true); + SelfopProgressProjection.applyCheckpoint(task,run,phase,unit,completed,total,scan); + tasks.updateById(task); runs.updateById(run); + } + + @Transactional + public void heartbeat(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease); var task=locks.lockTask(taskId); var run=locks.lockRun(runId);check(task,run,lease,true); + run.setHeartbeatAtUtc(utcNow()); run.setUpdatedAt(utcNow());runs.updateById(run); + } + + /** Freeze before replacing task progress, including crash takeover under the new valid fence. */ + @Transactional + public void freeze(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);check(task,run,lease,false); + SelfopProgressProjection.freezeRun(task,run);runs.updateById(run); + } + + private void check(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,boolean active) { + require(task!=null&&run!=null&&Objects.equals(run.getTaskId(),task.getId()),"SELFOP_PROGRESS_RUN_MISMATCH"); + require(Objects.equals(lease.scopeId(),lease.account()?task.getDiscoveryScopeId():task.getScopeId()),"SELFOP_PROGRESS_SCOPE_MISMATCH"); + if(active)require(Objects.equals(run.getFenceToken(),lease.fence())&&Objects.equals(task.getAttemptCount(),run.getAttemptNo()) + &&run.getFinishedAtUtc()==null,"SELFOP_FENCE_REJECTED"); + } + private void persist(SelfopSyncTask task,Map p) { + task.setProgressJson(bounded(p));task.setProgressUpdatedAtUtc(utcNow());task.setUpdatedAt(utcNow());tasks.updateById(task); + } + private static String bounded(Map p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;} + private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";} + @SuppressWarnings("unchecked") private static Map progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);} + @SuppressWarnings("unchecked") private static Map map(Object value){return value instanceof Map?new LinkedHashMap<>((Map)value):new LinkedHashMap<>();} + private static Long number(Object value){return value instanceof Number n?n.longValue():null;} + private static String instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java new file mode 100644 index 000000000..3bbab41ec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopReferenceRecovery { + private final SelfopSyncStageMapper stages; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopCoordinationMapper locks; + private final SelfopLeaseService leases; + private final SelfopTaskService taskService; + private final SelfopDiscoveryService discovery; + private final TransactionTemplate tx; + public SelfopReferenceRecovery(SelfopSyncStageMapper stages,SelfopSyncTaskMapper tasks,SelfopSyncScopeMapper scopes, + SelfopOutboundDiscoveryScopeMapper accounts,SelfopCoordinationMapper locks,SelfopLeaseService leases,SelfopTaskService taskService, + SelfopDiscoveryService discovery,TransactionTemplate tx){this.stages=stages;this.tasks=tasks;this.scopes=scopes;this.accounts=accounts; + this.locks=locks;this.leases=leases;this.taskService=taskService;this.discovery=discovery;this.tx=tx;} + public void refresh(SelfopSyncTask task,Map held){ + SelfopCollectionPolicy.requireBusiness(task); + if(task.getRoutingManifestJson()==null||"APPLIED".equals(task.getApplyState()))return; + var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + for(var object:manifest.getObjects().entrySet()){ + var ref=object.getValue();require(ref.getEffectiveStageId()!=null,"SELFOP_DETAILS_INCOMPLETE"); + var current=stages.selectById(ref.getEffectiveStageId());var latest=stages.selectOne(new QueryWrapper() + .eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",object.getKey()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1")); + if(latest==null||latest.getObservationSequence()<=current.getObservationSequence())continue; + for(var lease:held.values())leases.renew(lease); + var account=accounts.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount())); + var accountLease=leases.acquireDiscovery(account.getId(),UUID.randomUUID().toString()); + try{ + var refresh=new SelfopSyncTask();refresh.setSourceAccount(task.getSourceAccount());refresh.setTaskRole("ACCOUNT_DISCOVERY");refresh.setDiscoveryScopeId(account.getId());refresh.setTaskType("RECHECK"); + refresh.setExecutionPurpose(task.getExecutionPurpose()); + refresh.setScopeHash(task.getScopeHash());refresh.setRequestKey("warehouse-refresh:"+task.getId()+":"+object.getKey().substring(0,16)+":"+latest.getObservationSequence()); + refresh.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId())));refresh.setRequestHash(hash(refresh.getQueryScopeJson())); + refresh=taskService.create(refresh); + if(!"SUCCEEDED".equals(refresh.getStatus())){var run=taskService.begin(refresh.getId(),accountLease);refresh=tasks.selectById(refresh.getId());discovery.collect(refresh,run,accountLease);} + refresh=tasks.selectById(refresh.getId());require("SUCCEEDED".equals(refresh.getStatus()),"SELFOP_RECHECK_YIELDED"); + var recovered=read(refresh.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(object.getKey()); + require(recovered!=null&&recovered.getEffectiveStageId()!=null,"SELFOP_RECHECK_INCOMPLETE"); + require(held.values().stream().anyMatch(l->scopes.selectById(l.scopeId()).getWarehouseCode().equals(recovered.getWarehouseCode())),"LOCK_SET_CHANGED"); + ref.setEffectiveStageId(recovered.getEffectiveStageId());ref.setWarehouseCode(recovered.getWarehouseCode());ref.setRefreshTaskId(refresh.getId());ref.setNewestSeenSequence(latest.getObservationSequence()); + tx.executeWithoutResult(s->{for(var lease:held.values())leases.verify(lease);leases.verify(accountLease);var locked=locks.lockTask(task.getId()); + locked.setRoutingManifestJson(json(manifest));locked.setUpdatedAt(utcNow());tasks.updateById(locked);task.setRoutingManifestJson(locked.getRoutingManifestJson());}); + }finally{leases.release(accountLease);} + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java new file mode 100644 index 000000000..067c7eefd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopSalesPublisher { + private final SelfopCoordinationMapper locks; + private final SelfopSyncScopeMapper scopes; + private final SelfopSyncRunMapper runs; + private final SelfopSyncTaskMapper tasks; + private final SelfopAnalyticsProperties config; + private final com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper aggregates; + public SelfopSalesPublisher(SelfopCoordinationMapper locks,SelfopSyncScopeMapper scopes,SelfopSyncRunMapper runs,SelfopSyncTaskMapper tasks,SelfopAnalyticsProperties config,com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper aggregates){ + this.locks=locks;this.scopes=scopes;this.runs=runs;this.tasks=tasks;this.config=config;this.aggregates=aggregates; + } + @Transactional + public void publish(long scopeId,long runId,String owner,long fence){ + require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED"); + var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var now=utcNow(); + SelfopCollectionPolicy.requireExecution(config,"BUSINESS");SelfopCollectionPolicy.requireBusiness(run); + require(scope!=null&&run!=null&&"OUTBOUND".equals(scope.getDataFamily()),"SELFOP_SALES_SCOPE_INVALID"); + SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),now); + if(Objects.equals(scope.getPublishedRunId(),runId)&&"PUBLISHED".equals(run.getStatus()))return; + require(scope.getSourceTimezone()!=null&&!scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED"); + require(config.getQuantityUnit()!=null&&!config.getQuantityUnit().isBlank(),"QUANTITY_UNIT_UNCONFIRMED"); + require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getScopeHash(),scope.getScopeHash())&&Objects.equals(run.getFenceToken(),fence) + &&"WAREHOUSE_SYNC".equals(run.getRunRole())&&"READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY"); + require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"HALF_APPLIED_FACTS"); + require(Objects.equals(run.getCalculatedFactRevision(),scope.getFactRevision()),"SELFOP_CANDIDATE_OBSOLETE"); + require(run.getBuildStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"SELFOP_CANDIDATE_OBSERVATION_CHANGED"); + require("COMPLETE".equals(run.getCoverageStatus())&&run.getCoveredFromUtc()!=null&&run.getCoveredUntilUtc()!=null,"SELFOP_COVERAGE_UNPROVEN"); + var task=locks.lockTask(run.getTaskId());require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED"); + SelfopCollectionPolicy.requireBusiness(task); + if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now)); + scope.setPublishedRunId(runId);scope.setPublishedFactRevision(run.getCalculatedFactRevision());scope.setRebuildRequired(0); + scope.setDiscoveryFromUtc(run.getCoveredFromUtc());scope.setDiscoveryUntilUtc(run.getCoveredUntilUtc());scope.setUpdatedAt(now);scopes.updateById(scope); + SelfopProgressProjection.applyCheckpoint(task,run,"PUBLISH","NONE",1,1L,1);SelfopProgressProjection.freezeRun(task,run); + run.setStatus("PUBLISHED");run.setPublishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setResultPublishedRunId(runId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task); + locks.completeCoveredTasks(scopeId,scope.getScopeHash(),runId,run.getCoveredFromUtc(),run.getCoveredUntilUtc(),now); + locks.completeReusedRuns(runId,now); + locks.completeCoveredMarkers(scopeId,scope.getScopeHash(),runId,run.getCoveredUntilUtc(),now); + } + @Transactional + public void reuse(long scopeId,long runId,long publishedId,String owner,long fence){ + SelfopCollectionPolicy.requireExecution(config,"BUSINESS");require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED"); + var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var task=locks.lockTask(run.getTaskId());var published=runs.selectById(publishedId); + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopCollectionPolicy.requireBusiness(published); + SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),utcNow()); + require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getFenceToken(),fence)&&Objects.equals(run.getScopeHash(),scope.getScopeHash()),"SELFOP_REUSE_SCOPE_INVALID"); + require(Objects.equals(scope.getPublishedRunId(),publishedId)&&"PUBLISHED".equals(published.getStatus())&&Objects.equals(published.getCalculatedFactRevision(),scope.getFactRevision()) + &&locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"SELFOP_REUSE_OBSOLETE"); + require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED"); + require(run.getFetchStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getFetchStartedAtUtc())==0,"SELFOP_REUSE_OBSERVATION_CHANGED"); + var now=utcNow();run.setCalculatedFactRevision(published.getCalculatedFactRevision());SelfopProgressProjection.freezeRun(task,run);run.setStatus("REUSED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setResultPublishedRunId(publishedId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",task.getId()).set("status","SUCCEEDED").set("result_published_run_id",publishedId).set("next_retry_at_utc",null).set("updated_at",now)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java new file mode 100644 index 000000000..378e878e5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java @@ -0,0 +1,119 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Persistent cron slots + immediate polling of durable work; never starts a live call by default. */ +@Service +public class SelfopSchedulePlanner { + private final SelfopAnalyticsProperties config; + private final SelfopTaskService taskService; + private final SelfopExecutionService execution; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopSyncTaskMapper tasks; + private final SelfopOutboundOrderMapper orders; + private final SelfopCoordinationMapper locks; + private final TransactionTemplate tx; + public SelfopSchedulePlanner(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopExecutionService execution, + SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,SelfopSyncTaskMapper tasks,SelfopOutboundOrderMapper orders,SelfopCoordinationMapper locks,TransactionTemplate tx){ + this.config=config;this.taskService=taskService;this.execution=execution;this.scopes=scopes;this.accounts=accounts;this.tasks=tasks;this.orders=orders;this.locks=locks;this.tx=tx; + } + @Scheduled(fixedDelayString="${baiyue.self-operated-analytics.poll-delay-ms:1000}") + public void tick(){ + if("OFF".equals(config.getCollectionMode()))return; + if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + var configured=taskService.initialize();var now=utcNow(); + for(var scope:configured){ + if(!"BUSINESS".equals(config.getCollectionMode()))break; + if("INVENTORY".equals(scope.getDataFamily()))plan(scope,now); + else if(config.getSourceQueryTimezone()!=null&&Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())){plan(scope,now);review(scope,now);} + } + for(var task:taskService.due(10))try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */} + } + public void plan(SelfopSyncScope preview,LocalDateTime now){ + SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone()); + if("OUTBOUND".equals(scope.getDataFamily())&&scope.getInitialUntilUtc()==null){ + scope.setInitialUntilUtc(now);scope.setInitialFromUtc(now.atZone(ZoneOffset.UTC).withZoneSameInstant(business).minusMonths(2).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + scope.setUpdatedAt(now);scopes.updateById(scope);} + var latest=tasks.selectOne(new QueryWrapper().eq("scope_id",scope.getId()).eq("scope_hash",scope.getScopeHash()).eq("task_type","SCHEDULED").isNotNull("planned_at_utc").orderByDesc("planned_at_utc").last("LIMIT 1")); + LocalDateTime from=latest==null?scope.getCreatedAt():latest.getPlannedAtUtc(); + ZonedDateTime slot=CronExpression.parse(scope.getScheduleCron()).next(from.atZone(ZoneOffset.UTC).withZoneSameInstant(business).minusNanos(1)); + ZonedDateTime last=null;int count=0; + while(slot!=null&&!slot.toInstant().isAfter(now.toInstant(ZoneOffset.UTC))&&count++<100){last=slot;slot=CronExpression.parse(scope.getScheduleCron()).next(slot);} + if(last==null&&latest==null)last=now.atZone(ZoneOffset.UTC).withZoneSameInstant(business); + if(last==null)return; + LocalDateTime planned=last.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + if(latest!=null&&Objects.equals(latest.getPlannedAtUtc(),planned))return; + var request=new SelfopSyncTask();request.setSourceAccount(scope.getSourceAccount());request.setTaskRole("WAREHOUSE_SYNC");request.setScopeId(scope.getId());request.setWarehouseCode(scope.getWarehouseCode()); + request.setTaskType("SCHEDULED");request.setPlannedAtUtc(planned);request.setScopeHash(scope.getScopeHash());request.setQueryScopeJson(scope.getScopeJson()); + request.setRequestKey("slot:"+scope.getWarehouseCode()+":"+scope.getDataFamily()+":"+scope.getScopeHash().substring(0,12)+":"+planned);request.setRequestHash(hash(List.of(scope.getScopeHash(),planned))); + if("OUTBOUND".equals(scope.getDataFamily())){ + // This durable slot marker routes through bounded shared account slices, never a month-long request. + request.setStatus("WAITING_SOURCE");request.setApplyState("NOT_APPLICABLE"); + // The fixed historical window is queued atomically with its first slot marker. + // Slice identity is independent of later 08:00/20:00 slots and publication progress. + if(latest==null){ + LocalDateTime start=scope.getInitialFromUtc(),until=scope.getInitialUntilUtc(); + while(start.isBefore(until)){LocalDateTime end=start.plusDays(1);if(end.isAfter(until))end=until; + shared(scope,start,end,"initial:"+start+":"+end,"BACKFILL",null,null);start=end;} + } + // Prior slots retain their durable pending work; the planning cursor can advance + // before publication without skipping any interval. Re-read the recent overlap. + LocalDateTime start=latest==null?scope.getInitialUntilUtc():latest.getPlannedAtUtc().minusHours(48); + if(start.isBefore(scope.getInitialUntilUtc()))start=scope.getInitialUntilUtc(); + while(start.isBefore(planned)){LocalDateTime end=start.plusDays(1);if(end.isAfter(planned))end=planned; + shared(scope,start,end,"schedule:"+planned+":"+start,"SCHEDULED",null,null);start=end;} + } + taskService.create(request); + }); + } + private SelfopSyncTask shared(SelfopSyncScope scope,LocalDateTime from,LocalDateTime until,String key,String type,String customer,String delivery){ + var account=accounts.selectOne(new QueryWrapper().eq("source_account",scope.getSourceAccount())); + var t=new SelfopSyncTask();t.setSourceAccount(scope.getSourceAccount());t.setTaskRole("ACCOUNT_DISCOVERY");t.setDiscoveryScopeId(account.getId()); + t.setTaskType(type);t.setScopeHash(hash(config.getWarehouses()));t.setRequestKey(key+":"+t.getScopeHash().substring(0,12));t.setQueryFromUtc(from);t.setQueryUntilUtc(until); + ZoneId source=ZoneId.of(config.getSourceQueryTimezone()); + t.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from==null?null:from.atZone(ZoneOffset.UTC).withZoneSameInstant(source).toLocalDateTime().toString(), + until==null?null:until.atZone(ZoneOffset.UTC).withZoneSameInstant(source).toLocalDateTime().toString(),delivery,customer,null))); + t.setRequestHash(hash(Arrays.asList(t.getQueryScopeJson(),t.getScopeHash())));return taskService.create(t); + } + public void review(SelfopSyncScope preview,LocalDateTime now){ + SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId()); + if(scope.getKnownOrderRoundMaxId()==null){var max=orders.selectOne(new QueryWrapper().eq("source_account",scope.getSourceAccount()).eq("warehouse_code",scope.getWarehouseCode()).orderByDesc("id").last("LIMIT 1")); + scope.setKnownOrderRoundMaxId(max==null?0L:max.getId());scope.setKnownOrderCursorId(0L);scope.setKnownOrderRoundStartedAtUtc(now);} + var next=orders.selectOne(new QueryWrapper().eq("source_account",scope.getSourceAccount()).eq("warehouse_code",scope.getWarehouseCode()) + .gt("id",scope.getKnownOrderCursorId()).le("id",scope.getKnownOrderRoundMaxId()).orderByAsc("id").last("LIMIT 1")); + if(next!=null){String key="review:"+scope.getId()+":"+scope.getKnownOrderRoundStartedAtUtc()+":"+next.getId(); + var t=shared(scope,null,null,key,"RECHECK",next.getCustomerCode(),next.getDeliveryNo()); + long pending=tasks.selectCount(new QueryWrapper().eq("upstream_task_id",t.getId()).ne("apply_state","APPLIED")); + long applied=tasks.selectCount(new QueryWrapper().eq("upstream_task_id",t.getId()).eq("apply_state","APPLIED")); + if("SUCCEEDED".equals(t.getStatus())&&pending==0&&applied>0)scope.setKnownOrderCursorId(next.getId()); + }else{scope.setKnownOrderRoundFinishedAtUtc(now); + scope.setKnownOrderRoundMaxId(null);scope.setKnownOrderCursorId(null); + scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper().eq("id",scope.getId()) + .set("known_order_round_max_id",null).set("known_order_cursor_id",null).set("known_order_round_finished_at_utc",now));} + if(scope.getDiscoveryUntilUtc()!=null){ + if(scope.getHistoryRoundUntilUtc()==null){scope.setHistoryRoundFromUtc(scope.getInitialFromUtc());scope.setHistoryRoundUntilUtc(scope.getDiscoveryUntilUtc());scope.setHistorySliceCursorUtc(scope.getInitialFromUtc());} + if(scope.getHistorySliceCursorUtc().isBefore(scope.getHistoryRoundUntilUtc())){ + var end=scope.getHistorySliceCursorUtc().plusDays(1);if(end.isAfter(scope.getHistoryRoundUntilUtc()))end=scope.getHistoryRoundUntilUtc(); + var t=shared(scope,scope.getHistorySliceCursorUtc(),end,"history:"+scope.getId()+":"+scope.getHistoryRoundUntilUtc()+":"+scope.getHistorySliceCursorUtc(),"RECHECK",null,null); + var child=tasks.selectOne(new QueryWrapper().eq("upstream_task_id",t.getId()).eq("scope_id",scope.getId())); + if(child!=null&&"APPLIED".equals(child.getApplyState()))scope.setHistorySliceCursorUtc(end); + }else{scope.setHistoryRoundFinishedAtUtc(now);scope.setHistoryRoundUntilUtc(null);scope.setHistorySliceCursorUtc(null);scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper().eq("id",scope.getId()) + .set("history_round_until_utc",null).set("history_slice_cursor_utc",null).set("history_round_finished_at_utc",now));} + } + scope.setUpdatedAt(now);scopes.updateById(scope); + }); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java new file mode 100644 index 000000000..e33da0bce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -0,0 +1,222 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.*; + +@Service +public class SelfopTaskService { + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopCoordinationMapper locks; + private final SelfopAnalyticsProperties config; + private final SelfopLeaseService leases; + public SelfopTaskService(SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts, + SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopCoordinationMapper locks,SelfopAnalyticsProperties config,SelfopLeaseService leases) { + this.scopes=scopes;this.accounts=accounts;this.tasks=tasks;this.runs=runs;this.locks=locks;this.config=config;this.leases=leases; + } + @Transactional + public List initialize() { + identity(config.getSourceAccount(),64); require(!config.getWarehouses().isEmpty(),"SELFOP_WAREHOUSE_UNCONFIGURED"); + var now=utcNow(); + var a=accounts.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount())); + if(a==null){a=new SelfopOutboundDiscoveryScope();a.setId(IdWorker.getId());a.setSourceAccount(config.getSourceAccount()); + a.setNextSequence(1L);a.setNextObservationSequence(1L);a.setFenceToken(0L); + a.setRequestBudget(config.getRequestBudget());a.setTimeBudgetSeconds(config.getTimeBudgetSeconds()); + a.setMinRequestIntervalMs(config.getMinRequestIntervalMs());a.setCreatedAt(now);a.setUpdatedAt(now); + require(a.getRequestBudget()>0&&a.getTimeBudgetSeconds()>0&&a.getMinRequestIntervalMs()>0,"SELFOP_BUDGET_INVALID"); + try{accounts.insert(a);}catch(DuplicateKeyException ignored){ } + } + for(var e:config.getWarehouses().entrySet()) for(String family:List.of("OUTBOUND","INVENTORY")) { + identity(e.getKey(),64);var p=e.getValue();ZoneId.of(p.getBusinessTimezone()); + var existing=scopes.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("warehouse_code",e.getKey()).eq("data_family",family)); + Map definition=new TreeMap<>();definition.put("customerMode","ALL_AUTHORIZED");definition.put("sourceAccount",config.getSourceAccount()); + definition.put("warehouseCode",e.getKey());definition.put("dataFamily",family);definition.put("businessTimezone",p.getBusinessTimezone()); + definition.put("sourceTimezone",p.getSourceTimezone());definition.put("quantityUnit",config.getQuantityUnit()); + definition.put("qualities",family.equals("INVENTORY")?"ALL":"ACTUAL_OUTBOUND"); + definition.put("staleAfterSeconds",family.equals("INVENTORY")?p.getInventoryStaleAfterSeconds():p.getOutboundStaleAfterSeconds()); + String digest=hash(definition); + if(existing!=null){ + if("VALIDATION".equals(config.getCollectionMode()))continue; + if(!existing.getScopeHash().equals(digest)){ + existing=locks.lockScope(existing.getId()); + var update=new UpdateWrapper().eq("id",existing.getId()) + .set("scope_json",json(definition)).set("scope_hash",digest).set("business_timezone",p.getBusinessTimezone()).set("source_timezone",p.getSourceTimezone()) + .set("schedule_cron",family.equals("OUTBOUND")?p.getOutboundCron():p.getInventoryCron()) + .set("stale_after_seconds",family.equals("OUTBOUND")?p.getOutboundStaleAfterSeconds():p.getInventoryStaleAfterSeconds()) + .set("fence_token",existing.getFenceToken()+1).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",now); + if("OUTBOUND".equals(family)){ + update.set("discovery_from_utc",null).set("discovery_until_utc",null).set("rebuild_required",1).set("invalidated_at_utc",now) + .set("fact_revision",existing.getFactRevision()+1).set("known_order_round_max_id",null).set("known_order_cursor_id",null) + .set("history_round_until_utc",null).set("history_slice_cursor_utc",null); + if(existing.getInitialUntilUtc()!=null)update.set("initial_from_utc",existing.getInitialUntilUtc().atZone(ZoneOffset.UTC) + .withZoneSameInstant(ZoneId.of(p.getBusinessTimezone())).minusMonths(2).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + } + scopes.update(null,update); + } + continue; + } + var s=new SelfopSyncScope();s.setId(IdWorker.getId());s.setSourceAccount(config.getSourceAccount());s.setWarehouseCode(e.getKey());s.setDataFamily(family); + s.setScopeJson(json(definition));s.setScopeHash(digest);s.setBusinessTimezone(p.getBusinessTimezone());s.setSourceTimezone(p.getSourceTimezone()); + s.setScheduleCron(family.equals("OUTBOUND")?p.getOutboundCron():p.getInventoryCron()); + s.setStaleAfterSeconds(family.equals("OUTBOUND")?p.getOutboundStaleAfterSeconds():p.getInventoryStaleAfterSeconds()); + s.setFactRevision(0L);s.setPublishedFactRevision(0L);s.setRebuildRequired(0);s.setFenceToken(0L); + s.setReviewRequestBudget(p.getReviewRequestBudget());s.setReviewTimeBudgetSeconds(p.getReviewTimeBudgetSeconds());s.setReviewTargetDays(p.getReviewTargetDays()); + require(s.getStaleAfterSeconds()>0&&s.getReviewRequestBudget()>0&&s.getReviewTimeBudgetSeconds()>0,"SELFOP_BUDGET_INVALID"); + s.setCreatedAt(now);s.setUpdatedAt(now);try{scopes.insert(s);}catch(DuplicateKeyException ignored){ } + } + return scopes.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()).orderByAsc("warehouse_code","data_family")); + } + @Transactional + public SelfopSyncTask create(SelfopSyncTask task) { + identity(task.getRequestKey(),128); var existing=tasks.selectOne(new QueryWrapper() + .eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey())); + if(existing!=null){require(existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;} + if(task.getExecutionPurpose()==null)task.setExecutionPurpose(configuredPurpose(config)); + requireExecution(config,task.getExecutionPurpose()); + var now=utcNow();task.setId(IdWorker.getId());task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now); + task.setTaskNo("LXCOL-"+now.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of("Asia/Shanghai")).toLocalDate().toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,16).toUpperCase(Locale.ROOT)); + if("VALIDATION".equals(task.getExecutionPurpose())){ + require("VALIDATION".equals(task.getTaskType()),"SELFOP_VALIDATION_ENTRY_REQUIRED"); + if(task.getValidationRootTaskId()==null){task.setValidationRootTaskId(task.getId());task.setValidationBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopValidationBudget.freeze(now));} + task.setApplyState("NOT_APPLICABLE"); + } + task.setStatus(task.getStatus()==null?"READY":task.getStatus()); + task.setApplyState(task.getApplyState()==null?"NOT_APPLICABLE":task.getApplyState()); + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())) { + var account=locks.lockDiscovery(task.getDiscoveryScopeId());task.setDiscoverySequence(account.getNextSequence()); + task.setObservationBaseline(account.getNextObservationSequence()-1);account.setNextSequence(account.getNextSequence()+1);account.setUpdatedAt(now);accounts.updateById(account); + } + try{tasks.insert(task);}catch(DuplicateKeyException ignored){existing=tasks.selectOne(new QueryWrapper() + .eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey()).last("FOR UPDATE")); + require(existing!=null&&existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;} + return task; + } + @Transactional + public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) { + leases.verify(lease); + var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE"); + checkTaskLease(task,lease); + requireExecution(config,task.getExecutionPurpose()); + require(!"SAMPLE_DONE".equals(task.getStatus()),"SELFOP_VALIDATION_TERMINATED"); + if(lease.account()) {var account=locks.lockDiscovery(lease.scopeId());account.setActiveTaskId(taskId);account.setUpdatedAt(utcNow());accounts.updateById(account);} + var now=utcNow();var active=runs.selectOne(new QueryWrapper().eq("task_id",taskId).eq("status","FETCHING").orderByDesc("id").last("LIMIT 1")); + if(task.getScopeId()!=null && "INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { + var yielded=runs.selectOne(new QueryWrapper().eq("task_id",taskId).eq("status","YIELDED").orderByDesc("id").last("LIMIT 1")); + if(yielded!=null){yielded.setStatus("FETCHING");yielded.setFinishedAtUtc(null);yielded.setFenceToken(lease.fence());yielded.setUpdatedAt(now); + runs.update(null,new UpdateWrapper().eq("id",yielded.getId()).set("status","FETCHING").set("fence_token",lease.fence()).set("finished_at_utc",null).set("updated_at",now)); + task.setStatus("RUNNING");task.setUpdatedAt(now);tasks.updateById(task);return yielded;} + } + if(active!=null){require(!Objects.equals(active.getFenceToken(),lease.fence()),"SELFOP_TASK_ALREADY_RUNNING"); + SelfopProgressProjection.freezeRun(task,active); + active.setStatus("ABORTED");active.setLastPhase("FETCHING");active.setFinishedAtUtc(now);active.setErrorCode("LEASE_EXPIRED");active.setErrorMessage("执行租约失效");active.setUpdatedAt(now);runs.updateById(active);} + if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { + // Every genuinely new inventory attempt starts at page one, including a crash that never called fail(). + task.setRoutingManifestJson(null); + tasks.update(null,new UpdateWrapper().eq("id",taskId).set("routing_manifest_json",null)); + } + var run=new SelfopSyncRun();run.setId(IdWorker.getId());run.setTaskId(taskId);run.setScopeId(task.getScopeId()); + run.setExecutionPurpose(task.getExecutionPurpose());run.setHeartbeatAtUtc(now); + run.setSourceAccount(task.getSourceAccount());run.setWarehouseCode(task.getWarehouseCode());run.setRunRole(task.getTaskRole());run.setUpstreamTaskId(task.getUpstreamTaskId()); + run.setAttemptNo(task.getAttemptCount()+1);run.setScopeHash(task.getScopeHash());run.setScopeSnapshotJson(task.getQueryScopeJson()); + run.setFenceToken(lease.fence());run.setStatus("FETCHING");run.setLastPhase("FETCHING");run.setFetchStartedAtUtc(now); + run.setPageCount(0);run.setUniqueObjectCount(0L);run.setCoverageStatus("UNKNOWN");run.setCreatedAt(now);run.setUpdatedAt(now); + if(task.getScopeId()!=null){var scope=scopes.selectById(task.getScopeId());run.setBasePublishedRunId(scope.getPublishedRunId()); + Map snapshot=new TreeMap<>(read(scope.getScopeJson(),Map.class)); + snapshot.put("initialFromUtc",scope.getInitialFromUtc());snapshot.put("initialUntilUtc",scope.getInitialUntilUtc()); + snapshot.put("businessTimezone",scope.getBusinessTimezone());snapshot.put("sourceTimezone",scope.getSourceTimezone()); + snapshot.put("staleAfterSeconds",scope.getStaleAfterSeconds());run.setScopeSnapshotJson(json(snapshot)); + if("INVENTORY".equals(scope.getDataFamily())){var sales=scopes.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount()).eq("warehouse_code",task.getWarehouseCode()).eq("data_family","OUTBOUND")); + if(sales!=null)sales=locks.lockScope(sales.getId()); + run.setReferenceSalesRunId(sales==null?null:sales.getPublishedRunId());}} + runs.insert(run);task.setAttemptCount(run.getAttemptNo());task.setStatus("RUNNING");task.setUpdatedAt(now); + String initialPhase=lease.account()?"DIRECTORY":("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())?"INVENTORY":"MERGE"); + SelfopProgressProjection.applyCheckpoint(task,run,initialPhase,"PAGE",0,null,1);tasks.updateById(task);return run; + } + @Transactional + public void yield(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease); + var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setStatus("YIELDED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(now.plusSeconds(1));task.setUpdatedAt(now);tasks.updateById(task); + } + @Transactional + public void fail(long taskId,long runId,String code,boolean retryable,SelfopLeaseService.Lease lease) { + leases.verify(lease); + var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("FAILED");run.setErrorCode(code); + run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + // A yielded execution is not a failed request; finite failures are counted separately. + long failures=runs.selectCount(new QueryWrapper().eq("task_id",taskId).eq("status","FAILED")); + task.setStatus(retryable&&failures().eq("id",taskId).set("routing_manifest_json",null)); + } + if(Set.of("SELFOP_DIRECTORY_DUPLICATE","SELFOP_DIRECTORY_COUNT_MISMATCH","SELFOP_DIRECTORY_UNSTABLE","SELFOP_PAGE_TOTAL_CHANGED").contains(code)) { + task.setRoutingManifestJson(null); + tasks.update(null,new UpdateWrapper().eq("id",taskId).set("routing_manifest_json",null)); + } + task.setNextRetryAtUtc(now.plusSeconds(Math.min(3600,5L<().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey); + var prior=tasks.selectOne(commandQuery); + if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return preview;} + // Match executor lock ordering: account/warehouse scope first, task second. + String activeOwner;LocalDateTime activeUntil; + if("ACCOUNT_DISCOVERY".equals(preview.getTaskRole())){var scope=locks.lockDiscovery(preview.getDiscoveryScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} + else{var scope=locks.lockScope(preview.getScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} + prior=tasks.selectOne(new QueryWrapper().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey).last("FOR UPDATE")); + if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return tasks.selectById(id);} + require(SelfopLeaseService.available(activeOwner,activeUntil,utcNow()),"SELFOP_RETRY_LEASE_BUSY"); + var original=locks.lockTask(id);require(original!=null,"SELFOP_TASK_MISSING"); + requireBusiness(original);requireExecution(config,original.getExecutionPurpose()); + require(!"SUCCEEDED".equals(original.getStatus()),"SELFOP_TASK_ALREADY_SUCCEEDED"); + // Resume original logical task, retaining apply barrier and stage set. Record the manual command separately. + var audit=new SelfopSyncTask();audit.setSourceAccount(original.getSourceAccount());audit.setTaskRole(original.getTaskRole());audit.setScopeId(original.getScopeId()); + audit.setExecutionPurpose(original.getExecutionPurpose()); + audit.setWarehouseCode(original.getWarehouseCode());audit.setDiscoveryScopeId(original.getDiscoveryScopeId());audit.setUpstreamTaskId(original.getId()); + audit.setRequestKey("retry:"+requestKey);audit.setRequestHash(hash(List.of(id,requestKey,reason)));audit.setTaskType("MANUAL_RETRY");audit.setScopeHash(original.getScopeHash()); + audit.setQueryScopeJson(json(Map.of("resumesTaskNo",original.getTaskNo(),"reason",reason)));audit.setStatus("SUCCEEDED");audit.setApplyState("NOT_APPLICABLE");audit.setRequestedBy(operator); + create(audit); + original.setStatus("READY");original.setNextRetryAtUtc(utcNow());original.setRequestedBy(operator);original.setUpdatedAt(utcNow());tasks.updateById(original); + return original; + } + public List due(int limit){return locks.due(utcNow(),limit,config.getCollectionMode());} + @Transactional + public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId); + checkRunLease(task,run,lease); + require("READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY"); + run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); + // Candidate readiness is not business success until a published result is linked. + task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(utcNow().plusSeconds(config.getMinimumBuildIntervalSeconds()));task.setUpdatedAt(utcNow());tasks.updateById(task); + } + @Transactional + public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) { + leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run); + SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("YIELDED");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); + task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(until);task.setUpdatedAt(utcNow());tasks.updateById(task); + } + private static void checkTaskLease(SelfopSyncTask task,SelfopLeaseService.Lease lease){ + require(task!=null&&(lease.account()?"ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&Objects.equals(task.getDiscoveryScopeId(),lease.scopeId()) + :"WAREHOUSE_SYNC".equals(task.getTaskRole())&&Objects.equals(task.getScopeId(),lease.scopeId())),"SELFOP_TASK_LEASE_SCOPE_MISMATCH"); + } + private static void checkRunLease(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease){ + checkTaskLease(task,lease);require(run!=null&&Objects.equals(run.getTaskId(),task.getId())&&Objects.equals(run.getFenceToken(),lease.fence()),"SELFOP_RUN_LEASE_MISMATCH"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java new file mode 100644 index 000000000..3ad5c8a3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java @@ -0,0 +1,208 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.*; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import lombok.Data; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.time.Duration; +import java.util.*; +import java.util.function.Supplier; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Bounded sample workflow. Deliberately has no facts, scope, SKU, candidate or publisher dependencies. */ +@Service +public class SelfopValidationRunner { + private final LingxingAnalyticsClient client; + private final SelfopSyncStageMapper stages; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopCoordinationMapper locks; + private final SelfopLeaseService leases; + private final SelfopEgressService egress; + private final SelfopAnalyticsProperties config; + private final TransactionTemplate tx; + public SelfopValidationRunner(LingxingAnalyticsClient client,SelfopSyncStageMapper stages, + SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopCoordinationMapper locks, + SelfopLeaseService leases,SelfopEgressService egress,SelfopAnalyticsProperties config,TransactionTemplate tx) { + this.client=client;this.stages=stages;this.tasks=tasks;this.runs=runs;this.locks=locks; + this.leases=leases;this.egress=egress;this.config=config;this.tx=tx; + } + + public void run(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) { + require("VALIDATION".equals(config.getCollectionMode())&&"VALIDATION".equals(task.getExecutionPurpose()) + &&"VALIDATION".equals(run.getExecutionPurpose()),"SELFOP_VALIDATION_PURPOSE_REQUIRED"); + require(task.getValidationRootTaskId()!=null,"SELFOP_VALIDATION_ROOT_REQUIRED"); + require(lease.account(),"SELFOP_ACCOUNT_LEASE_REQUIRED"); + var context=SelfopRequestContext.forTask(task,run,config); + try { + var state=task.getRoutingManifestJson()==null?new Sample():read(task.getRoutingManifestJson(),Sample.class); + Map query=read(task.getQueryScopeJson(),Map.class); + var from=LocalDateTime.parse(Objects.toString(query.get("startTime"),"")); + var until=LocalDateTime.parse(Objects.toString(query.get("endTime"),"")); + require(until.isAfter(from)&&Duration.between(from,until).compareTo(Duration.ofDays(1))<=0,"SELFOP_VALIDATION_WINDOW_INVALID"); + if(!state.warehousesRead) { + leases.renew(lease); var page=request(task,run,lease,()->client.warehouses(context,1,10)); validatePage(page,1); + tx.executeWithoutResult(s->{check(task,run,lease); + for(var warehouse:page.records()) { + identity(warehouse.warehouseCode(),64); + } + run.setObservedDimensionsJson(json(Map.of("validationWarehouseSamples",page.records(),"truncated",page.pages()>1))); + state.warehousesRead=true;state.pages++;save(task,run,state,"DIRECTORY","PAGE",state.pages,null); + }); + } + while(!state.directoryRead&&state.outboundPage<=2) { + int current=state.outboundPage;leases.renew(lease); + var page=request(task,run,lease,()->client.discover(context,from,until,current,10));validatePage(page,current); + tx.executeWithoutResult(s->{check(task,run,lease); + for(var summary:page.records()) { + require(Objects.equals(task.getSourceAccount(),summary.sourceAccount()),"SELFOP_SAMPLE_ACCOUNT_MISMATCH"); + identity(summary.customerCode(),64);identity(summary.warehouseCode(),64);identity(summary.deliveryNo(),128); + String key=hash(List.of(summary.customerCode(),summary.deliveryNo())); + require(state.orders.stream().noneMatch(o->key.equals(hash(List.of(o.customerCode(),o.deliveryNo())))),"SELFOP_SAMPLE_DIRECTORY_DUPLICATE"); + state.orders.add(summary); + stage(task,run,"OUTBOUND_ORDER","directory:"+key,summary.warehouseCode(),current,summary,"PENDING",null); + } + state.outboundPage++;state.pages++;state.directoryRead=page.pages()==0||current>=page.pages()||state.outboundPage>2; + save(task,run,state,"DIRECTORY","PAGE",state.pages,null); + }); + } + while(state.detailIndexclient.detail(context,summary.warehouseCode(),summary.customerCode(),summary.deliveryNo())); + validateOrder(task,summary,detail); + tx.executeWithoutResult(s->{check(task,run,lease); + boolean unknown=detail.items().stream().anyMatch(i->i.actualQuantity()==null); + boolean invalid=detail.items().stream().anyMatch(i->i.actualQuantity()!=null&&i.actualQuantity().signum()<0); + stage(task,run,"OUTBOUND_ORDER",hash(List.of(detail.customerCode(),detail.deliveryNo())),detail.warehouseCode(),1,detail, + invalid?"INVALID":unknown?"PENDING":"VALID",invalid?"NEGATIVE_QUANTITY":unknown?"QUANTITY_UNKNOWN":null); + state.detailIndex++;save(task,run,state,"DETAIL","ORDER",state.detailIndex,(long)state.orders.size()); + }); + } + // Each configured warehouse is sampled independently, never inferred from directory absence. + for(String warehouse:config.getWarehouses().keySet()) { + int current=state.inventoryNextPage.getOrDefault(warehouse,1); + while(current<=2&&!state.inventoryFinished.contains(warehouse)) { + leases.renew(lease);int pageNo=current; + var page=request(task,run,lease,()->client.inventory(context,warehouse,pageNo,10));validatePage(page,current); + tx.executeWithoutResult(s->{check(task,run,lease); + for(var stock:page.records()) { + require(Objects.equals(stock.sourceAccount(),task.getSourceAccount())&&warehouse.equals(stock.warehouseCode()),"SELFOP_SAMPLE_INVENTORY_IDENTITY"); + identity(stock.customerCode(),64);identity(stock.skuCode(),128);require(stock.productQuality()!=null,"SELFOP_SAMPLE_INVENTORY_STRUCTURE"); + String key=hash(List.of(stock.customerCode(),stock.warehouseCode(),stock.skuCode(),stock.productQuality())); + require(state.inventoryKeys.add(key),"SELFOP_SAMPLE_INVENTORY_DUPLICATE"); + boolean unknown=stock.physicalQuantity()==null||stock.availableQuantity()==null||stock.frozenQuantity()==null||stock.transitQuantity()==null; + boolean invalid=Arrays.asList(stock.physicalQuantity(),stock.availableQuantity(),stock.frozenQuantity(),stock.transitQuantity()).stream().filter(Objects::nonNull).anyMatch(q->q.signum()<0); + stage(task,run,"PRODUCT_STOCK",key,warehouse,pageNo,stock,invalid?"INVALID":unknown?"PENDING":"VALID",invalid?"NEGATIVE_QUANTITY":unknown?"QUANTITY_UNKNOWN":null); + } + state.inventoryNextPage.put(warehouse,pageNo+1);state.pages++;state.inventoryRows+=page.records().size(); + if(page.pages()==0||pageNo>=page.pages()||pageNo==2)state.inventoryFinished.add(warehouse); + save(task,run,state,"INVENTORY","INVENTORY_ROW",state.inventoryRows,null); + });current++; + } + } + finish(task,run,lease,"VALIDATION_SAMPLE_SUFFICIENT",false); + } catch(EgressRejectedException stop) { + // No YIELDED or retry scheduling: every validation root is a single finite session. + finish(task,run,lease,stop.sampleDone()?stop.code():"VALIDATION_REQUEST_REJECTED",!stop.sampleDone()); + } catch(AnalyticsClientException failure) { + finish(task,run,lease,failure.code(),true); + } catch(RuntimeException invalid) { + if("SELFOP_FENCE_REJECTED".equals(invalid.getMessage())||"SELFOP_LEASE_BUSY".equals(invalid.getMessage()))throw invalid; + finish(task,run,lease,"VALIDATION_STRUCTURE_REJECTED",true); + } + } + + /** Execution-level failures still terminate the original bounded validation session. */ + public void fail(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,String code) { + finish(task,run,lease,code,true); + } + + private void check(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) { + leases.verify(lease);var stored=locks.lockTask(task.getId());var active=locks.lockRun(run.getId()); + require(stored!=null&&active!=null&&Objects.equals(stored.getDiscoveryScopeId(),lease.scopeId()) + &&Objects.equals(active.getTaskId(),stored.getId())&&Objects.equals(active.getFenceToken(),lease.fence()) + &&Objects.equals(stored.getAttemptCount(),active.getAttemptNo())&&"VALIDATION".equals(stored.getExecutionPurpose()) + &&"VALIDATION".equals(active.getExecutionPurpose())&&Objects.equals(stored.getSourceAccount(),task.getSourceAccount()) + &&Objects.equals(active.getSourceAccount(),stored.getSourceAccount()) + &&Objects.equals(stored.getValidationRootTaskId(),task.getValidationRootTaskId()),"SELFOP_FENCE_REJECTED"); + } + private void stage(SelfopSyncTask task,SelfopSyncRun run,String type,String key,String warehouse,int page,Object payload,String status,String code) { + // VALID outbound stages require an observation sequence in the schema. Samples never + // acquire a formal sequence: structure success remains explicit pending sample evidence. + if("VALID".equals(status)){status="PENDING";code="SAMPLE_VALIDATED";} + String keyHash=hash(key); + var prior=stages.selectOne(new QueryWrapper().eq("stage_batch_id",task.getId()).eq("object_type",type).eq("object_key_hash",keyHash)); + if(prior!=null){require(prior.getContentHash().equals(hash(payload)),"SELFOP_SAMPLE_STAGE_CHANGED");return;} + var row=new SelfopSyncStage();row.setId(IdWorker.getId());row.setStageBatchId(task.getId());row.setRunId(run.getId());row.setLastFetchRunId(run.getId()); + row.setObjectType(type);row.setObjectKeyHash(keyHash);row.setWarehouseCode(warehouse);row.setFirstPageNo(page);row.setObservedAtUtc(utcNow()); + row.setContentHash(hash(payload));row.setPayloadJson(json(payload));row.setValidationStatus(status);row.setValidationCode(code); + row.setCreatedAt(utcNow());row.setUpdatedAt(utcNow());stages.insert(row); + } + private void save(SelfopSyncTask task,SelfopSyncRun run,Sample state,String phase,String unit,long completed,Long total) { + task.setRoutingManifestJson(json(state));SelfopProgressProjection.applyCheckpoint(task,run,phase,unit,completed,total,1); + tasks.update(null,new UpdateWrapper().eq("id",task.getId()).set("routing_manifest_json",task.getRoutingManifestJson()) + .set("progress_json",task.getProgressJson()).set("progress_updated_at_utc",task.getProgressUpdatedAtUtc()).set("updated_at",utcNow())); + runs.updateById(run); + } + private void finish(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,String code,boolean failed) { + // Quota owns root stop reason and consumed allowance. Never overwrite it from a stale task object. + tx.executeWithoutResult(s->{check(task,run,lease); + if(failed)egress.failValidation(task.getValidationRootTaskId(),code);else egress.finishSample(task.getValidationRootTaskId(),code); + var saved=locks.lockTask(task.getId());var attempt=locks.lockRun(run.getId()); + boolean dead=failed||"DEAD".equals(saved.getStatus())||"FAILED".equals(attempt.getStatus()); + SelfopProgressProjection.applyCheckpoint(saved,attempt,dead?"FAILED":"SAMPLE_DONE","NONE",0,null,1); + SelfopProgressProjection.freezeRun(saved,attempt);attempt.setStatus(dead?"FAILED":"SAMPLE_DONE"); + if(attempt.getErrorCode()==null)attempt.setErrorCode(code); + attempt.setCoverageStatus("UNKNOWN");attempt.setFetchFinishedAtUtc(utcNow());attempt.setFinishedAtUtc(utcNow());attempt.setUpdatedAt(utcNow());runs.updateById(attempt); + tasks.update(null,new UpdateWrapper().eq("id",task.getId()).set("status",dead?"DEAD":"SAMPLE_DONE") + .set("next_retry_at_utc",null).set("progress_json",saved.getProgressJson()).set("progress_updated_at_utc",saved.getProgressUpdatedAtUtc()).set("updated_at",utcNow())); + }); + } + private T request(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,Supplier request) { + while(true) { + try{return request.get();} + catch(EgressRejectedException wait) { + if(!Set.of("ACCOUNT_INTERVAL_WAIT","ACCOUNT_REQUEST_INFLIGHT").contains(wait.code())||wait.waitUntilUtc()==null)throw wait; + var root=tasks.selectById(task.getValidationRootTaskId()); + require(root!=null&&root.getValidationBudgetJson()!=null,"SELFOP_VALIDATION_ROOT_REQUIRED"); + var budget=read(root.getValidationBudgetJson(),SelfopValidationBudget.class); + if(!wait.waitUntilUtc().isBefore(budget.deadlineAtUtc))throw new EgressRejectedException("VALIDATION_DEADLINE",true,null); + while(utcNow().isBefore(wait.waitUntilUtc())) { + leases.renew(lease); + try{Thread.sleep(Math.min(1000,Math.max(1,Duration.between(utcNow(),wait.waitUntilUtc()).toMillis())));} + catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new EgressRejectedException("VALIDATION_INTERRUPTED",false,null);} + } + } + } + } + private static void validatePage(LingxingAnalyticsClient.Page page,int current) { + require(page.current()!=null&&page.current()==current&&page.size()!=null&&page.size()==10&&page.total()!=null&&page.total()>=0 + &&page.pages()!=null&&page.pages()>=0&&page.records().size()<=10,"SELFOP_SAMPLE_PAGE_INVALID"); + } + private static void validateOrder(SelfopSyncTask task,LingxingAnalyticsClient.Summary summary,SelfopPayloads.Order order) { + require(Objects.equals(task.getSourceAccount(),order.sourceAccount())&&Objects.equals(summary.warehouseCode(),order.warehouseCode()) + &&Objects.equals(summary.customerCode(),order.customerCode())&&Objects.equals(summary.deliveryNo(),order.deliveryNo()),"SELFOP_SAMPLE_DETAIL_IDENTITY"); + require(order.sourceStatus()!=null&&order.items()!=null,"SELFOP_SAMPLE_DETAIL_STRUCTURE"); + for(var item:order.items())identity(item.skuCode(),128); + } + @Data + public static class Sample { + private boolean warehousesRead; + private int pages; + private int outboundPage=1; + private boolean directoryRead; + private List orders=new ArrayList<>(); + private int detailIndex; + private Map inventoryNextPage=new LinkedHashMap<>(); + private Set inventoryFinished=new LinkedHashSet<>(); + private Set inventoryKeys=new LinkedHashSet<>(); + private long inventoryRows; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java new file mode 100644 index 000000000..f2f8b21cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@RestController +public class SyncController { + private final SelfopCommandService commands; + public SyncController(SelfopCommandService commands){this.commands=commands;} + public record Accepted(String taskNo,String warehouseCode,String status) { } + @PostMapping("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks") + @PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')") + @Operation(summary="创建自营分析人工采集或恢复任务",description="平台独立写权限 platform:self-operated-analytics:sync;持久化后异步执行,执行开关默认关闭。BACKFILL使用不超过一天的来源时间切片;未知源时区仅允许暂存。") + public ApiResponse request(@AuthenticationPrincipal AuthenticatedUser user,@RequestBody SelfopCommandService.Command command){ + if(user==null||user.tenantId()==null||user.tenantId()!=0||!"PLATFORM".equals(user.appCode())||user.impersonated()||!user.permissions().contains("platform:self-operated-analytics:sync")) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅限已授权平台人员"); + var task=commands.request(command,String.valueOf(user.userId()));return ApiResponse.success(new Accepted(task.getTaskNo(),command.warehouseCode(),"ACCEPTED")); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java index 1152f9cd4..919f9f544 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java @@ -1,8 +1,10 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.lastleg.service.reference.ReturnCustomerReferenceClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; @@ -16,12 +18,15 @@ import java.util.Map; @Service public class ReturnOrderReferenceResolver { private static final Logger log = LoggerFactory.getLogger(ReturnOrderReferenceResolver.class); - private final FleetCustomerDirectory customers; + private final ReturnCustomerReferenceClient customers; private final WarehouseResourceGateway warehouses; + private final String internalToken; - public ReturnOrderReferenceResolver(FleetCustomerDirectory customers, WarehouseResourceGateway warehouses) { + public ReturnOrderReferenceResolver(ReturnCustomerReferenceClient customers, WarehouseResourceGateway warehouses, + @Value("${baiyue.security.internal-service-token}") String internalToken) { this.customers = customers; this.warehouses = warehouses; + this.internalToken = internalToken; } public void populate(List orders) { @@ -33,14 +38,14 @@ public class ReturnOrderReferenceResolver { } private void populate(List orders, boolean required) { - Map customerReferences = new HashMap<>(); + Map customerReferences = new HashMap<>(); Map warehouseReferences = new HashMap<>(); for (LastlegOrder order : orders) { if (!"RETURN_RELABEL".equals(order.getOrderType())) continue; if (!StringUtils.hasText(order.getCustomerName())) { try { - var customer = customerReferences.computeIfAbsent(order.getCustomerId(), customers::find); - order.setCustomerName(customer.name()); + var customer = customerReferences.computeIfAbsent(order.getCustomerId(), this::customerReference); + order.setCustomerName(customer.tenantName()); } catch (ResponseStatusException error) { if (required) throw error; log.warn("Return customer reference unavailable for order {}, status {}", order.getId(), error.getStatusCode().value()); @@ -59,4 +64,18 @@ public class ReturnOrderReferenceResolver { } } } + + private ReturnCustomerReferenceClient.Customer customerReference(String id) { + try { + var result = customers.customers(internalToken, 1, 30, null, "ACTIVE", Long.parseLong(id)); + if (result == null || !result.successful() || result.data() == null) + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试"); + return result.data().records().stream() + .filter(row -> row.id() != null && row.id() > 0 && row.id().toString().equals(id) && "ACTIVE".equals(row.status())) + .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户")); + } catch (ResponseStatusException error) { throw error; } + catch (RuntimeException error) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试"); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java new file mode 100644 index 000000000..691919b9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.service.reference; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** IAM owns customer references used to fill return-order snapshots. */ +@FeignClient(name = "iam-service", contextId = "returnCustomerReferenceClient", url = "${baiyue.clients.iam.url:}") +public interface ReturnCustomerReferenceClient { + @GetMapping("/internal/iam/finance-customers") + ApiResponse> customers(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("page") long page, @RequestParam("size") long size, @RequestParam("keyword") String keyword, + @RequestParam("status") String status, @RequestParam("customerId") Long customerId); + + record Customer(Long id, String tenantCode, String tenantName, String status, String contactName, String contactPhone) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java new file mode 100644 index 000000000..5f7f6a42e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.transfer.gateway; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import java.util.List; + +/** Authoritative warehouse catalog for resolving the frozen transfer destination. */ +@FeignClient(name = "warehouse-service", contextId = "transferWarehouseCatalogClient", + url = "${baiyue.clients.domestic-warehouse.url:}") +public interface TransferWarehouseCatalogClient { + @GetMapping("/internal/warehouses/order-catalog") + ApiResponse> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token); + + record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java index e952751bf..619498af3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java @@ -2,7 +2,7 @@ package com.baiyue.lastleg.transfer.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; -import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.baiyue.lastleg.transfer.gateway.TransferWarehouseCatalogClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; @@ -41,7 +41,7 @@ public class WarehouseTransferReceiptCoordinator { @Resource private WarehouseTransferReceiptCommandMapper commands; @Resource private WarehouseResourceGateway warehouse; @Resource private DomesticWarehouseDirectoryClient directory; - @Resource private FleetWarehouseClient catalog; + @Resource private TransferWarehouseCatalogClient catalog; @Resource private PlatformTransactionManager transactionManager; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String internalToken; diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 21524c7a4..b7d66926e 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -46,6 +46,18 @@ lastleg: enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} baiyue: + self-operated-analytics: + collection-mode: ${SELFOP_COLLECTION_MODE:OFF} + environment: ${DEPLOY_ENV:test} + daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0} + business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000} + execution-enabled: ${SELFOP_EXECUTION_ENABLED:false} + sales-publication-enabled: ${SELFOP_SALES_PUBLICATION_ENABLED:false} + integration-base-url: http://integration-service:9210 + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + source-account: ${SELFOP_SOURCE_ACCOUNT:lingxing-primary} + source-query-timezone: Asia/Shanghai + quantity-unit: PCS http: clients: unload-photo-file: diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e8f84b81c..dfb7edc76 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3588,3 +3588,304 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + + +-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。 +CREATE TABLE ll_selfop_outbound_discovery_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖', + catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖', + next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧', + next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次', + active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务', + lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者', + lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务', + request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正', + time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正', + min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置', + api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据', + api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除', + api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_discovery_account (source_account), + CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域'; + +CREATE TABLE ll_selfop_sync_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥', + scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单', + scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合', + business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准', + source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区', + schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行', + stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整', + warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表', + initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月', + initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间', + discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界', + discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点', + fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加', + published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号', + rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1', + invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻', + published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次', + lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁', + lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验', + known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标', + known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识', + known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间', + known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间', + history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界', + history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界', + history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置', + history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻', + review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正', + review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正', + review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family), + KEY idx_selfop_scope_lease (lease_until_utc), + CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针'; + +CREATE TABLE ll_selfop_sync_task ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识', + execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承', + validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空', + validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器', + upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游', + discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定', + observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', + request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', + task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', + planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', + query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', + query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', + query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号', + routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷', + scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要', + status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功', + apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE', + affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补', + apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间', + apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数', + next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻', + requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计', + result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空', + progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合', + progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_task_no (task_no), + UNIQUE KEY uk_selfop_task_request (source_account,request_key), + KEY idx_selfop_task_poll (status,next_retry_at_utc,id), + KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code), + KEY idx_selfop_task_apply (apply_state,id), + KEY idx_selfop_task_scope_list (scope_id,created_at,id), + KEY idx_selfop_task_account_list (source_account,task_role,created_at,id), + CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求'; + +CREATE TABLE ll_selfop_sync_run ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_id BIGINT NOT NULL COMMENT '本次执行请求', + execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本', + scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合', + attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增', + scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要', + scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版', + observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围', + fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本', + base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本', + reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实', + inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空', + inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存', + status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头', + last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实', + fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间', + fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空', + build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', + finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间', + published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间', + superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', + page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数', + source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空', + unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数', + progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写', + heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进', + coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断', + covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界', + covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点', + complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含', + complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含', + applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空', + calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号', + error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息', + error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no), + UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date), + KEY idx_selfop_run_scope (scope_id,id), + KEY idx_selfop_run_status (status,updated_at), + CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')), + CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头'; + +CREATE TABLE ll_selfop_sync_stage ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试', + stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页', + last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源', + observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓', + object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK', + object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对', + first_page_no INT NOT NULL COMMENT '首次发现页码', + observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻', + content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要', + payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段', + before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空', + validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常', + validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash), + KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id), + KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence), + KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status), + CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据'; + +CREATE TABLE ll_selfop_outbound_order ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份', + source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示', + source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码', + source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代', + source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代', + source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区', + outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空', + business_date DATE NULL COMMENT '已核准业务时区下的出库日', + business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日', + sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1', + revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数', + content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要', + last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试', + last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no), + KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id), + KEY idx_selfop_order_run (last_run_id), + CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实'; + +CREATE TABLE ll_selfop_outbound_item ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + order_id BIGINT NOT NULL COMMENT '对应外部出库事实', + sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku', + product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示', + actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值', + quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order_sku (order_id,sku_code), + KEY idx_selfop_item_sku (sku_code,order_id), + CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细'; + +CREATE TABLE ll_selfop_inventory_snapshot ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存', + product_name VARCHAR(255) NULL COMMENT '来源名称', + physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空', + available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结', + frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空', + transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存', + source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据', + observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间', + quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生', + quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality), + KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细'; + +CREATE TABLE ll_selfop_sku_daily_sales ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + business_date DATE NOT NULL COMMENT '已核准业务日', + outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负', + outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负', + quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date), + KEY idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code), + CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; + +CREATE TABLE ll_selfop_sku_profile ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称', + quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标', + first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据', + earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移', + evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据', + evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空', + last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code), + KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据'; diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml new file mode 100644 index 000000000..518f93306 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml @@ -0,0 +1,115 @@ +openapi: 3.0.3 +info: + title: Self-operated Analytics Internal Egress Authority + version: 1.0.0 +paths: + /internal/lastleg/self-operated-analytics/egress/authorize: + post: + operationId: authorizeSelfoperatedEgressAttempt + summary: 一次性消费已持久预留的领星请求许可 + description: >- + 仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配; + requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定, + 后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1, + expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝, + 非 production 环境拒绝 BUSINESS。独占交接证明及接续预算由服务端配置核验。 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AuthorizationRequest' } + responses: + '200': + description: HTTP 200;许可 code=200,正常拒绝 code=B0001,data 为显式预算决策 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: + oneOf: + - { $ref: '#/components/schemas/AttemptPermit' } + - { $ref: '#/components/schemas/AuthorizationDenial' } + '401': { description: 内部令牌无效 } + '503': { description: 内部令牌未配置 } + /internal/lastleg/self-operated-analytics/egress/complete: + post: + operationId: completeSelfoperatedEgressAttempt + summary: 记录真实尝试次数和账号冷却 + description: >- + 回调 context、operation、requestHash 必须匹配已消费许可。actualHttpAttempts 仅 0 或 1; + 即使 0 也不退回预留,响应丢失同样保留保守消耗。重复已确认完成为幂等成功。 + 429 对账号全部采集族持久冷却;验证根 DEAD、run FAILED、禁止自动续跑。 + 未确认结果的许可到期后保守冷却,不使用换日、换仓、重试或重启清零预算。 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CompletionRequest' } + responses: + '200': + description: ApiResponse code 字符串 200,data 为布尔 true + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { type: boolean, enum: [true] } + '401': { description: 内部令牌无效 } +components: + securitySchemes: + internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token } + schemas: + EgressContext: + type: object + required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId] + properties: + sourceAccount: { type: string, maxLength: 64 } + executionPurpose: { type: string, enum: [VALIDATION, BUSINESS] } + rootTaskId: { type: string, pattern: '^[0-9]+$' } + sessionId: { type: string, maxLength: 128 } + attemptId: { type: string, maxLength: 128 } + exclusiveGrantId: { type: string, maxLength: 128 } + Operation: + type: string + enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY] + AuthorizationRequest: + type: object + required: [context, operation, requestHash] + properties: + context: { $ref: '#/components/schemas/EgressContext' } + operation: { $ref: '#/components/schemas/Operation' } + requestHash: { type: string, pattern: '^[a-f0-9]{64}$' } + AuthorizationDenial: + type: object + required: [errorCode, sampleDone, waitUntil] + properties: + errorCode: { type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$' } + sampleDone: { type: boolean } + waitUntil: { type: string, format: date-time, nullable: true, description: UTC Instant 或显式 null } + AttemptPermit: + allOf: + - { $ref: '#/components/schemas/AuthorizationRequest' } + - type: object + required: [allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed] + properties: + allowedAttempts: { type: integer, enum: [1] } + expiresAt: { type: string, format: date-time } + exclusiveConfirmed: { type: boolean, enum: [true] } + budgetConsumed: { type: boolean, enum: [true] } + CompletionRequest: + allOf: + - { $ref: '#/components/schemas/AuthorizationRequest' } + - type: object + required: [actualHttpAttempts, accountPaused] + properties: + actualHttpAttempts: { type: integer, minimum: 0, maximum: 1 } + errorCode: { type: string, nullable: true } + upstreamStatus: { type: integer, nullable: true } + sourceCode: { type: integer, nullable: true } + accountPaused: { type: boolean } + cooldownUntil: { type: string, format: date-time, nullable: true } diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml new file mode 100644 index 000000000..b9a0b893c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml @@ -0,0 +1,62 @@ +openapi: 3.0.3 +info: + title: Platform Self-operated Analytics Sync Commands + version: 1.0.0 +paths: + /api/platform/v1/last-leg/self-operated-analytics/sync-tasks: + post: + operationId: requestSelfoperatedAnalyticsSyncTask + summary: 创建人工采集、复查、重建或恢复任务 + description: >- + 仅平台账号且须 platform:self-operated-analytics:sync 权限。命令先持久化, + 不在 HTTP 请求内运行历史采集。collectionMode 默认 OFF;VALIDATION 仅用于持久预算内的样本暂存, + BUSINESS 仅允许生产环境,并要求真实出口取得服务端核验的人工独占许可。 + 出库来源时间及业务时间统一 Asia/Shanghai;首次正式目标固定为 E0 前两个滚动月。 + BACKFILL 单片不超过一天。RETRY 使用 taskNo 和非空 reason 恢复原分仓逻辑任务, + 保留半归并屏障及旧尝试,拒绝验证会话复活、账号共享任务伪装单仓、有效执行租约及上游未齐; + requestKey 同键异载荷拒绝。成功受理只表示排队,不表示采集或发布成功。 + security: + - bearerAuth: [] + x-required-authorities: + - platform:self-operated-analytics:sync + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SyncCommand' + responses: + '200': + description: 平台 ApiResponse 包络,成功 code 为字符串 200;返回稳定业务任务号与 ACCEPTED + content: + application/json: + schema: + type: object + properties: + code: { type: string } + message: { type: string } + data: + type: object + properties: + taskNo: { type: string } + warehouseCode: { type: string } + status: { type: string, enum: [ACCEPTED] } + '401': { description: 未认证 } + '403': { description: 非平台身份或无独立写权限 } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + SyncCommand: + type: object + required: [requestKey, warehouseCode, action] + properties: + requestKey: { type: string, minLength: 1, maxLength: 100 } + warehouseCode: { type: string, minLength: 1, maxLength: 64 } + action: { type: string, enum: [INVENTORY, BACKFILL, RECHECK, REBUILD, RETRY, VALIDATION] } + taskNo: { type: string, minLength: 1, maxLength: 64, description: RETRY 必填,原始分仓业务任务号 } + reason: { type: string, minLength: 1, maxLength: 500, description: RETRY 必填,随操作人和原任务审计保存 } + sourceFrom: { type: string, example: '2026-10-11T00:00:00' } + sourceUntil: { type: string, example: '2026-10-12T00:00:00' } + customerCode: { type: string, maxLength: 64, description: RECHECK 必填 } + deliveryNo: { type: string, maxLength: 128, description: RECHECK 必填 } diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml new file mode 100644 index 000000000..805e7a4e3 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -0,0 +1,700 @@ +openapi: 3.0.3 +info: + title: 平台自营出库销量库存及采集运行只读接口 + version: 1.0.0 + description: >- + 全部GET要求PLATFORM身份、tenantId=0、非代登录及独立platform:self-operated-analytics:list权限。 + 来源及业务时区Asia/Shanghai,单位PCS,账号和仓库由服务端已配置范围校验。 + 经营查询首屏生成context,包含版本对、独立范围摘要、queryAsOf/queryExpiresAt。 + 后续列表、图表、榜单和库存复用完整上下文,版本0保持不可用,不偷偷补入新版本。 + 上下文最多30分钟且不超过旧销量版superseded_at_utc+7天;queryAsOf不得未来或早于所选版本发布时间。 + 过期/范围不符明确要求刷新整页,不能静默切版;各请求在同一REPEATABLE_READ快照内校验和读取。 + 五周期包含今日,CUSTOM两端包含且endDate不晚于anchorDate;只有CUSTOM接受startDate/endDate。 + 默认maxQueryDays366,粒度DAY。正式outboundQuantity与observedQuantity独立,全部未知仍为空。 + 每日COMPLETE仅证明全天覆盖及有效日行或可靠SKU存在;今日/部分日正式量空,观察量可用,未知日期保持空。 + SKU区间正式量仅全部日完整才输出;observedQuantity为可靠点合计,完整/部分/未知日数独立。 + 切换图表不改变完整7/30日榜单及30日日均分母;不触发领星请求。 + 运行接口不受经营context限制。动态库存时效、重建和修订待核对独立返回。 + 阶段进度区分ACCOUNT_SHARED/WAREHOUSE及TASK_CUMULATIVE/ATTEMPT_ONLY,不伪造总百分比。 + total未知为空、计数不能跨PAGE/ORDER/INVENTORY_ROW/BUSINESS_KEY/SKU/BATCH单位求和,NONE为非计数阶段且completed/total为空、totalKnown=false;阶段检查点与心跳分开。 + 任务号稳定,内部诊断ID不作为业务编号回退。SKU订单数不可相加为仓总单数。 + 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary: + get: + summary: SKU五周期或历史累计清单 + description: '候选为两版profile并集,SQL按选定日期聚合后按observedQuantity降序空值末尾、SKU及货主升序,再分页。未知日不填零。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} + - {$ref: '#/components/parameters/bucket'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/SalesPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sku-daily-sales: + get: + summary: 精确单SKU日趋势 + description: 'customerCode和sku必填,精确原始业务键匹配。最多366日且返回完整升序日期轴,不分页。无候选明确业务错误。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCodeRequired'} + - {$ref: '#/components/parameters/skuRequired'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} + - {$ref: '#/components/parameters/bucket'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Trend'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/hot-skus: + get: + summary: 完整7/30日热销Top100 + description: '独立截至queryAsOf昨日的完整窗口,数量大于零,数量降序、SKU单数降序、SKU及货主升序,先Top100再分页;当前缺货仍可入榜。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/windowDays'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/MetricPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/slow-skus: + get: + summary: 固定完整30日有库存低出库Top100 + description: '固定30天,不接受windowDays或rangePreset。正品有效新鲜可售量大于零、完整窗口且起点前可靠存在。数量升序、可售量降序、SKU及货主升序。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/MetricPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/inventory-coverage: + get: + summary: 正品库存与固定30日可售天数 + description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/attention'} + - {$ref: '#/components/parameters/sortBy'} + - {$ref: '#/components/parameters/coverageDaysThreshold'} + + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/MetricPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-overview: + get: + summary: 各仓任务族运行概览 + description: 'warehouseCode可选。轻量读取scope/task/run/账号保护。无发布版和OFF仍返回配置任务。首采COMPLETE须正式发布覆盖且无rebuild或未闭合归并。共享上游明确账号共享范围。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCodeOptional'} + - {$ref: '#/components/parameters/dataFamily'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Overview'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-tasks: + get: + summary: 本仓逻辑采集任务 + description: '任务按创建时间及稳定键倒序。首屏返回listUpperCreatedAt/listUpperId,后页完整重传;新插入不挤移页。只读有限检查点,不展开stage或大manifest。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/dataFamily'} + - {$ref: '#/components/parameters/taskType'} + - {$ref: '#/components/parameters/status'} + - {$ref: '#/components/parameters/taskNo'} + - {$ref: '#/components/parameters/dateFrom'} + - {$ref: '#/components/parameters/dateUntil'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + - {$ref: '#/components/parameters/listUpperCreatedAt'} + - {$ref: '#/components/parameters/listUpperId'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/TaskPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-tasks/{taskNo}: + get: + summary: 本仓业务任务号详情 + description: '路径taskNo为稳定业务任务号,实际归属与请求账号仓必须一致。不以内部ID回退。详情含最新尝试和关联共享上游。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {name: taskNo, in: path, required: true, schema: {type: string, maxLength: 64}} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/TaskDetail'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-runs: + get: + summary: 本仓或任务号的尝试历史 + description: '纯运行元数据分页。当前尝试读同一attemptNo的task进度,终态尝试只读run冻结快照;心跳过期UNCONFIRMED,不自动改判成功失败。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/dataFamily'} + - {$ref: '#/components/parameters/taskType'} + - {$ref: '#/components/parameters/status'} + - {$ref: '#/components/parameters/taskNo'} + - {$ref: '#/components/parameters/dateFrom'} + - {$ref: '#/components/parameters/dateUntil'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + - {$ref: '#/components/parameters/listUpperCreatedAt'} + - {$ref: '#/components/parameters/listUpperId'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/AttemptPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/owners: + get: + summary: 已采集货主编码分页 + description: 固定同屏版本,只读权限platform:self-operated-analytics:list。不调用外部API。概览不接受SKU/attention/sortBy/分页/图表范围,候选不存在data为空。 + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/keyword'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: 统一ApiResponse + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/OwnerPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/owner-overview: + get: + summary: 单货主完整候选30日经营概览 + description: 固定同屏版本,只读权限platform:self-operated-analytics:list。不调用外部API。概览不接受SKU/attention/sortBy/分页/图表范围,候选不存在data为空。 + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/customerCodeRequired'} + - {$ref: '#/components/parameters/coverageDaysThreshold'} + responses: + '200': + description: 统一ApiResponse + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/OwnerOverview'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + keyword: {name: keyword, in: query, schema: {type: string, minLength: 1, maxLength: 64}, description: 区分大小写的货主编码字面前缀,先过滤去重再分页} + attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}} + sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_30D_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序} + coverageDaysThreshold: {name: coverageDaysThreshold, in: query, schema: {type: integer, minimum: 1}, description: COVERAGE_GT_THRESHOLD必填,严格大于且未舍入比较,不设置默认经营政策} + sourceAccount: {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + warehouseCode: {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + customerCode: {name: customerCode, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 64}} + sku: {name: sku, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 128}} + rangePreset: {name: rangePreset, in: query, required: false, schema: {type: string, enum: [TODAY, LAST_3_DAYS, THIS_WEEK, LAST_15_DAYS, THIS_MONTH, CUSTOM], default: TODAY}} + startDate: {name: startDate, in: query, required: false, schema: {type: string, format: date}} + endDate: {name: endDate, in: query, required: false, schema: {type: string, format: date}} + bucket: {name: bucket, in: query, required: false, schema: {type: string, enum: [DAY], default: DAY}} + windowDays: {name: windowDays, in: query, required: false, schema: {type: integer, enum: [7, 30], default: 30}} + current: {name: current, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + size: {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + salesRunId: {name: salesRunId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}} + inventoryRunId: {name: inventoryRunId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}} + salesScopeHash: {name: salesScopeHash, in: query, required: false, schema: {type: string, pattern: '^[0-9a-f]{64}$'}} + inventoryScopeHash: {name: inventoryScopeHash, in: query, required: false, schema: {type: string, pattern: '^[0-9a-f]{64}$'}} + queryAsOf: {name: queryAsOf, in: query, required: false, schema: {type: string, format: date-time}} + dataFamily: {name: dataFamily, in: query, required: false, schema: {type: string, enum: [OUTBOUND, INVENTORY]}} + taskType: {name: taskType, in: query, required: false, schema: {type: string}} + status: {name: status, in: query, required: false, schema: {type: string}} + taskNo: {name: taskNo, in: query, required: false, schema: {type: string, maxLength: 64}} + dateFrom: {name: dateFrom, in: query, required: false, schema: {type: string, format: date}} + dateUntil: {name: dateUntil, in: query, required: false, schema: {type: string, format: date}} + listUpperCreatedAt: {name: listUpperCreatedAt, in: query, required: false, schema: {type: string, format: date-time}} + listUpperId: {name: listUpperId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}} + warehouseCodeOptional: {name: warehouseCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} + customerCodeRequired: {name: customerCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + skuRequired: {name: sku, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + schemas: + OwnerOption: + type: object + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + OwnerPage: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + records: {type: array, items: {$ref: '#/components/schemas/OwnerOption'}} + current: {type: integer} + size: {type: integer} + total: {type: string} + pages: {type: string} + excludedReasons: {type: object, additionalProperties: {type: string}} + QuantityTotal: + type: object + properties: + overallTotal: {type: number, nullable: true} + knownSubtotal: {type: number, nullable: true} + unknownSkuCount: {type: string} + dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]} + reasonCodes: {type: array, items: {type: string}} + RiskShare: + type: object + description: 分母为同一OwnerOverview的physicalQuantity。未知时正式命中与比例为空。未设置阈值返回THRESHOLD_NOT_SET。 + properties: + matchedSkuCount: {type: string, nullable: true} + matchedPhysicalQuantity: {type: number, nullable: true} + physicalQuantityRatio: {type: number, minimum: 0, maximum: 1, nullable: true} + knownMatchedSkuCount: {type: string, nullable: true} + knownMatchedPhysicalQuantity: {type: number, nullable: true} + unassessableSkuCount: {type: string} + unassessableKnownPhysicalQuantity: {type: number, nullable: true} + reasonCodes: {type: array, items: {type: string}} + OwnerOverview: + type: object + nullable: true + properties: + context: {$ref: '#/components/schemas/Context'} + customerCode: {type: string} + customerName: {type: string, nullable: true} + skuCount: {type: string, nullable: true} + physicalSkuCount: {$ref: '#/components/schemas/QuantityTotal'} + physicalQuantity: {$ref: '#/components/schemas/QuantityTotal'} + availableQuantity: {$ref: '#/components/schemas/QuantityTotal'} + frozenQuantity: {$ref: '#/components/schemas/QuantityTotal'} + defectivePhysicalQuantity: {$ref: '#/components/schemas/QuantityTotal'} + transitQuantity: {$ref: '#/components/schemas/QuantityTotal'} + outboundQuantity30d: {$ref: '#/components/schemas/QuantityTotal'} + averageDailyOutboundQuantity: {$ref: '#/components/schemas/QuantityTotal'} + noOutbound30d: {$ref: '#/components/schemas/RiskShare'} + coverageAboveThreshold: {$ref: '#/components/schemas/RiskShare'} + Context: + type: object + properties: + salesRunId: {type: string, nullable: true} + inventoryRunId: {type: string, nullable: true} + salesScopeHash: {type: string, nullable: true} + inventoryScopeHash: {type: string, nullable: true} + queryAsOf: {type: string, format: date-time, nullable: true} + queryExpiresAt: {type: string, format: date-time, nullable: true} + businessTimezone: {type: string, nullable: true} + anchorDate: {type: string, format: date, nullable: true} + rangeStartDate: {type: string, format: date, nullable: true} + rangeEndDateExclusive: {type: string, format: date, nullable: true} + calculationWindowDays: {type: integer, nullable: true} + completeDayFrom: {type: string, format: date, nullable: true} + completeDayUntil: {type: string, format: date, nullable: true} + salesCoveredFrom: {type: string, format: date-time, nullable: true} + salesCoveredUntil: {type: string, format: date-time, nullable: true} + salesPublishedAt: {type: string, format: date-time, nullable: true} + inventoryObservedFrom: {type: string, format: date-time, nullable: true} + inventoryObservedUntil: {type: string, format: date-time, nullable: true} + salesStatus: {type: string, nullable: true} + inventoryStatus: {type: string, nullable: true} + rebuildRequired: {type: boolean, nullable: true} + revisionReviewStatus: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + SalesSummary: + type: object + properties: + sourceAccount: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + customerCode: {type: string, nullable: true} + sku: {type: string, nullable: true} + productName: {type: string, nullable: true} + quantityUnit: {type: string, nullable: true} + outboundQuantity: {type: number, nullable: true} + observedQuantity: {type: number, nullable: true} + dataStatus: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + completeDayCount: {type: integer, nullable: true} + partialDayCount: {type: integer, nullable: true} + unknownDayCount: {type: integer, nullable: true} + Point: + type: object + properties: + businessDate: {type: string, format: date, nullable: true} + outboundQuantity: {type: number, nullable: true} + observedQuantity: {type: number, nullable: true} + dataStatus: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + quantityUnit: {type: string, nullable: true} + coveredFrom: {type: string, format: date-time, nullable: true} + coveredUntil: {type: string, format: date-time, nullable: true} + Trend: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + summary: {$ref: '#/components/schemas/SalesSummary'} + points: {type: array, items: {$ref: '#/components/schemas/Point'}} + Metric: + type: object + properties: + customerName: {type: string, nullable: true} + defectivePhysicalQuantity: {type: number, nullable: true} + outboundQuantity30d: {type: number, nullable: true} + lastObservedOutboundDate: {type: string, format: date, nullable: true} + observedHistoryStartDate: {type: string, format: date, nullable: true} + attentionReasons: {type: array, items: {type: string}} + quantityStatuses: {type: object, additionalProperties: {type: string, enum: [VALID, UNKNOWN, STALE]}} + sourceAccount: {type: string, nullable: true} + customerCode: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + sku: {type: string, nullable: true} + productName: {type: string, nullable: true} + quantityUnit: {type: string, nullable: true} + outboundQuantity: {type: number, nullable: true} + skuOutboundOrderCount: {type: string, nullable: true} + todayObservedQuantity: {type: number, nullable: true} + physicalQuantity: {type: number, nullable: true} + availableQuantity: {type: number, nullable: true} + frozenQuantity: {type: number, nullable: true} + transitQuantity: {type: number, nullable: true} + inventoryObservedAt: {type: string, format: date-time, nullable: true} + averageDailyOutboundQuantity: {type: number, nullable: true} + inventoryCoverageDays: {type: number, nullable: true} + inventoryQuality: {type: string, nullable: true} + salesCoverage: {type: string, nullable: true} + observationEligible: {type: boolean, nullable: true} + earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true} + status: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + Protection: + type: object + properties: + accountCooldownUntil: {type: string, format: date-time, nullable: true} + accountPauseReason: {type: string, nullable: true} + dailyRequestBudget: {type: integer, nullable: true} + reservedHttpRequests: {type: string, nullable: true} + confirmedHttpRequests: {type: string, nullable: true} + budgetDate: {type: string, format: date, nullable: true} + Validation: + type: object + properties: + validationSessionNo: {type: string, nullable: true} + budgetLimits: {type: object, additionalProperties: {type: string, nullable: true}} + budgetConsumed: {type: object, additionalProperties: {type: string, nullable: true}} + deadlineAt: {type: string, format: date-time, nullable: true} + stopReason: {type: string, nullable: true} + Attempt: + type: object + properties: + runId: {type: string, nullable: true} + taskNo: {type: string, nullable: true} + attemptNo: {type: integer, nullable: true} + status: {type: string, nullable: true} + phase: {type: string, nullable: true} + executionPurpose: {type: string, nullable: true} + startedAt: {type: string, format: date-time, nullable: true} + finishedAt: {type: string, format: date-time, nullable: true} + phaseStartedAt: {type: string, format: date-time, nullable: true} + progressUpdatedAt: {type: string, format: date-time, nullable: true} + heartbeatAt: {type: string, format: date-time, nullable: true} + executionHealth: {type: string, nullable: true} + elapsedSeconds: {type: string, nullable: true} + errorCode: {type: string, nullable: true} + failureSummary: {type: string, nullable: true} + retryable: {type: boolean, nullable: true} + coverageStatus: {type: string, nullable: true} + coveredFrom: {type: string, format: date-time, nullable: true} + coveredUntil: {type: string, format: date-time, nullable: true} + publishedAt: {type: string, format: date-time, nullable: true} + progress: {$ref: '#/components/schemas/Progress'} + Task: + type: object + properties: + taskNo: {type: string, nullable: true} + taskType: {type: string, nullable: true} + dataFamily: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + taskRole: {type: string, nullable: true} + executionPurpose: {type: string, nullable: true} + queryFrom: {type: string, format: date-time, nullable: true} + queryUntil: {type: string, format: date-time, nullable: true} + taskStatus: {type: string, nullable: true} + applyState: {type: string, nullable: true} + attemptCount: {type: integer, nullable: true} + latestAttemptNo: {type: integer, nullable: true} + resultPublicationState: {type: string, nullable: true} + resultPublishedRunId: {type: string, nullable: true} + nextRetryAt: {type: string, format: date-time, nullable: true} + allowedActions: {type: array, items: {type: string}} + actionBlockReason: {type: string, nullable: true} + progressUpdatedAt: {type: string, format: date-time, nullable: true} + progress: {$ref: '#/components/schemas/Progress'} + validation: {$ref: '#/components/schemas/Validation'} + latestAttempt: {$ref: '#/components/schemas/Attempt'} + TaskDetail: + type: object + properties: + task: {$ref: '#/components/schemas/Task'} + sharedUpstream: {$ref: '#/components/schemas/Task'} + protection: {$ref: '#/components/schemas/Protection'} + observedAt: {type: string, format: date-time, nullable: true} + InventoryDailySamples: + type: object + nullable: true + description: 仅库存任务族提供。北京时间近30个完整日的正式日代表积累;缺日不补零或沿用旧批次,不是周转率。 + required: [windowFrom, windowUntil, expectedDays, sampleCount, missingDates] + properties: + windowFrom: {type: string, format: date, description: 包含起日} + windowUntil: {type: string, format: date, description: 排他上界,即北京时间今日} + expectedDays: {type: integer, enum: [30]} + sampleCount: {type: integer, minimum: 0, maximum: 30} + missingDates: {type: array, items: {type: string, format: date}} + accumulationStartedOn: {type: string, format: date, nullable: true, description: 本仓最早正式日代表日期,无样本时为空} + Warehouse: + type: object + properties: + warehouseCode: {type: string, nullable: true} + warehouseName: {type: string, nullable: true} + dataFamily: {type: string, nullable: true} + executionEnabled: {type: boolean, nullable: true} + publicationEnabled: {type: boolean, nullable: true} + disabledReason: {type: string, nullable: true} + nextScheduledAt: {type: string, format: date-time, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} + initialFrom: {type: string, format: date-time, nullable: true} + initialUntil: {type: string, format: date-time, nullable: true} + initialCoverageStatus: {type: string, nullable: true} + salesCoveredFrom: {type: string, format: date-time, nullable: true} + salesCoveredUntil: {type: string, format: date-time, nullable: true} + lastSuccessfulTaskNo: {type: string, nullable: true} + lastSucceededAt: {type: string, format: date-time, nullable: true} + lastPublishedAt: {type: string, format: date-time, nullable: true} + inventoryObservedFrom: {type: string, format: date-time, nullable: true} + inventoryObservedUntil: {type: string, format: date-time, nullable: true} + dataFreshness: {type: string, nullable: true} + rebuildRequired: {type: boolean, nullable: true} + revisionReviewStatus: {type: string, nullable: true} + inventoryDailySamples: {$ref: '#/components/schemas/InventoryDailySamples'} + currentTask: {$ref: '#/components/schemas/Task'} + sharedUpstream: {$ref: '#/components/schemas/Task'} + protection: {$ref: '#/components/schemas/Protection'} + Overview: + type: object + properties: + sourceAccount: {type: string, nullable: true} + environmentLabel: {type: string, nullable: true} + collectionMode: {type: string, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} + warehouses: {type: array, items: {$ref: '#/components/schemas/Warehouse'}} + Progress: + type: object + properties: + progressScope: {type: string, nullable: true} + countBasis: {type: string, nullable: true} + attemptNo: {type: string, nullable: true} + scanRound: {type: string, nullable: true} + phase: {type: string, nullable: true} + completed: {type: string, nullable: true} + total: {type: string, nullable: true} + totalKnown: {type: boolean, nullable: true} + unit: {type: string, nullable: true} + sliceFrom: {type: string, format: date-time, nullable: true} + sliceUntil: {type: string, format: date-time, nullable: true} + stageStartedAt: {type: string, format: date-time, nullable: true} + stageFinishedAt: {type: string, format: date-time, nullable: true} + validationState: {type: string, nullable: true} + invalidCount: {type: string, nullable: true} + unknownCount: {type: string, nullable: true} + pendingReviewCount: {type: string, nullable: true} + countUnit: {type: string, nullable: true} + summaryAsOf: {type: string, format: date-time, nullable: true} + directoryComplete: {type: boolean, nullable: true} + stages: {type: array, items: {$ref: '#/components/schemas/Progress'}} + SalesPage: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + records: {type: array, items: {$ref: '#/components/schemas/SalesSummary'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + pages: {type: string, nullable: true} + excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}} + MetricPage: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + records: {type: array, items: {$ref: '#/components/schemas/Metric'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + pages: {type: string, nullable: true} + excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}} + TaskPage: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Task'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + listUpperCreatedAt: {type: string, format: date-time, nullable: true} + listUpperId: {type: string, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} + AttemptPage: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Attempt'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + listUpperCreatedAt: {type: string, format: date-time, nullable: true} + listUpperId: {type: string, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index 3f3910460..13f3ef39c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.appointmentplanning; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.controller.AppointmentSourceCreateController; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.*; import org.springframework.security.access.AccessDeniedException; @@ -19,7 +20,7 @@ class AppointmentPlanningAuthorizationTest { @Test void savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() { try(var context=new AnnotationConfigApplicationContext(Config.class)) { var controller=context.getBean(AppointmentSourceCreateController.class); - var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")), + var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8")), "100","ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"key"); login(LIST,CARGO,WRITE); assertThrows(AccessDeniedException.class,()->controller.create(user(0L,"PLATFORM"),request)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java new file mode 100644 index 000000000..18617b272 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.delivery.fleet; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetPoolDeliveryAdapterTest { + private final DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class); + private final FleetPoolDeliveryAdapter adapter=new FleetPoolDeliveryAdapter(evidence); + private final LocalDateTime signed=LocalDateTime.of(2026,10,12,12,0); + private void shipment(int total,long signatures,List picks,List returns){ + when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L)); + when(evidence.deliveries("9",70)).thenReturn(List.of(new Delivery(71,70,"CODE:ONT8",total,signatures,signed,signed.minusHours(1)))); + when(evidence.picks("9",70)).thenReturn(picks);when(evidence.returns("9",70)).thenReturn(returns); + } + private Pick pick(long id,long source,int quantity){return new Pick(id,70,"CODE:ONT8",source,quantity,id,null,null,null);} + @Test void completeActualSourceIsSignedButPartialSignatureIsNot(){ + shipment(4,2,List.of(pick(1,77,4)),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + shipment(4,4,List.of(pick(1,77,4)),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isTrue(); + } + @Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedSource(){ + shipment(8,6,List.of(pick(1,77,4),pick(2,78,4)),List.of(new Returned(9,71,78,2,signed.minusMinutes(5)))); + assertThat(adapter.fullySigned("9","WT-7",77,4)).isTrue(); + } + @Test void thisSourceReturnCannotBeCountedAsWholeSignature(){ + shipment(4,2,List.of(pick(1,77,4)),List.of(new Returned(9,71,77,2,signed.minusMinutes(5)))); + assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + } + @Test void missingOrDuplicatedDispatchEvidenceIsUnproved(){ + for(var ids:List.of(List.of(),List.of(70L,70L),List.of(0L))){ + when(evidence.dispatchIds("9",77)).thenReturn(ids);assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + } + verify(evidence,never()).deliveries(anyString(),anyLong()); + } + @Test void missingPickOrReturnHistoryIsUnproved(){ + shipment(4,4,List.of(),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + shipment(4,4,List.of(pick(1,77,4)),null);assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index 2729365a6..2bb70c73c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -1,15 +1,11 @@ package com.baiyue.lastleg.fleet.business; -import com.baiyue.lastleg.delivery.entity.DeliveryOrder; -import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; -import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; -import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.mapper.*; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; import com.baiyue.lastleg.trucking.mapper.*; -import com.baiyue.lastleg.service.LastlegOrderNoGenerator; + import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.springframework.transaction.annotation.*; @@ -26,15 +22,14 @@ class FleetPoolOrderBridgeTest { LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class); UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class); UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class); - LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class); - DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class); + FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class); + FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class); com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class); FleetPoolOrderBridge service; FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder; @BeforeEach void setup(){ service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); - when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L)); - when(evidence.returns("9",70)).thenReturn(List.of()); + when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); when(links.lockIncoming("9","WT-7")).thenAnswer(a->row); @@ -66,16 +61,13 @@ class FleetPoolOrderBridgeTest { doThrow(new IllegalStateException("appointment binding conflict")).when(appointmentSources).materialized("9","FB-7",77L); assertThatThrownBy(this::incoming).isInstanceOf(IllegalStateException.class).hasMessageContaining("appointment binding conflict"); } - FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-7","WT-7","92","FBA:ONT8","READY_FOR_DISPATCH","hash",List.of( - new FleetBookingIncomingGateway.Pallet("101","PALLET-1",2,BigDecimal.TEN,BigDecimal.ONE), - new FleetBookingIncomingGateway.Pallet("102","PALLET-2",2,BigDecimal.TEN,BigDecimal.ONE)),4);} + Receipt receipt(){return new Receipt("WT-7","92","FBA:ONT8","READY_FOR_DISPATCH",List.of( + new ReceivedPallet("101",2),new ReceivedPallet("102",2)),4);} void dispatch(String type){service.confirm(input(type),"op");incoming();service.receiving("9","WT-7","pda"); service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");} - DeliveryOrder delivery(int signed){var d=new DeliveryOrder();d.setId(71L);d.setDispatchOrderId(70L);d.setDeliveryGroupKey("CODE:ONT8");d.setCustomerId("9");d.setDispatchNo("DISPATCH-7");d.setDeliveryNo("DELIVERY-7"); - d.setStatus("DELIVERED");d.setPackageCount(4);d.setSignedPackages(signed);d.setSignedAt(LocalDateTime.now()); - when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",4,(long)signed,d.getSignedAt(),d.getSignedAt().minusHours(1)))); - when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null))); - return d;} + SignedDelivery delivery(int signed){ + when(evidence.fullySigned("9","WT-7",77,4)).thenReturn(signed>=4); + return new SignedDelivery("9","DISPATCH-7","DELIVERY-7",true,LocalDateTime.now());} @Test void looseboardConfirmationCreatesOnlyCommercialRecordAndReplaysWithoutAnotherOrder(){ var result=service.confirm(input("LOCAL_LOOSEBOARD"),"op"); assertThat(result.businessOrderNo()).isEqualTo("LOCAL-001");assertThat(looseOrder.getLastlegOrderId()).isNull(); @@ -130,9 +122,9 @@ class FleetPoolOrderBridgeTest { @Test void incomingBlocksCancellationAndWrongReceiptIdentityOrDuplicatePhysicalPallets(){ service.confirm(input("US_TRUCK"),"op");incoming(); assertThatThrownBy(()->service.cancelBeforeIncoming(7,"9","op","取消")).hasMessageContaining("已登记来货"); - var r=receipt();var wrong=new FleetBookingIncomingGateway.Evidence("OTHER",r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),r.pallets(),4); + var r=receipt();var wrong=new Receipt("OTHER",r.warehouseId(),r.destinationKey(),r.status(),r.pallets(),4); assertThatThrownBy(()->service.acceptedReceipt(7,wrong,3,"op")).hasMessageContaining("来源"); - var duplicate=new FleetBookingIncomingGateway.Evidence(r.incomingNo(),r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),List.of(r.pallets().get(0),r.pallets().get(0)),4); + var duplicate=new Receipt(r.incomingNo(),r.warehouseId(),r.destinationKey(),r.status(),List.of(r.pallets().get(0),r.pallets().get(0)),4); assertThatThrownBy(()->service.acceptedReceipt(7,duplicate,3,"op")).hasMessageContaining("重复"); } @Test void actualDepartureAndFullSignatureDriveLooseboardExactlyOnce(){ @@ -157,22 +149,15 @@ class FleetPoolOrderBridgeTest { assertThatThrownBy(()->service.confirm(missing,"op")).hasMessageContaining("独立实托数"); verifyNoInteractions(trucks,loose,links); } - @Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedCommercialOrder(){ + @Test void deliveryOwnedProofCompletesOnlyThisCommercialSource(){ dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now()); - var d=delivery(6);d.setPackageCount(8); - when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",8,6L,d.getSignedAt(),d.getSignedAt().minusHours(1)))); - when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null), - new SourceDeliveryCompletionPolicy.Pick(2,70,"CODE:ONT8",78,4,2L,null,null,null))); - when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,78,2,d.getSignedAt().minusMinutes(5)))); - service.signed(d,Map.of(77L,4L,78L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED"); + service.signed(delivery(4),Map.of(77L,4L,78L,4L),"op",LocalDateTime.now()); + assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");verify(evidence).fullySigned("9","WT-7",77,4); } - @Test void thisSourceReturnOrMissingHistoryCannotBeCountedAsWholeSignature(){ + @Test void missingDeliveryOwnedProofCannotCompleteCommercialOrder(){ dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now()); - var d=delivery(2); - when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,77,2,d.getSignedAt().minusMinutes(5)))); - service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT"); - d=delivery(4);when(evidence.dispatchIds("9",77)).thenReturn(List.of()); - service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT"); + service.signed(delivery(2),Map.of(77L,4L),"op",LocalDateTime.now()); + assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT"); } @Test void bindingDoesNotMeanDepartureAndCannotCompleteBeforeActualDeparture(){ dispatch("US_TRUCK");assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH"); @@ -185,6 +170,9 @@ class FleetPoolOrderBridgeTest { assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY); assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName())) .noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory")); + assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getPackageName())) + .noneMatch(name->name.startsWith("com.baiyue.lastleg.delivery")||name.equals("com.baiyue.lastleg.fleet.trip") + ||name.equals("com.baiyue.lastleg.service")); } @Test void poolOnlyCallbacksCannotSucceedWithoutTheirCommercialLink(){ assertThatThrownBy(()->service.acceptedReceipt(7,receipt(),1,"op")).hasMessageContaining("缺少唯一商业订单关联"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java new file mode 100644 index 000000000..76c6a83aa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.fleet.business; + +import org.junit.jupiter.api.Test; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import static org.assertj.core.api.Assertions.*; + +class FleetPoolOrderNumberGeneratorTest { + private final FleetPoolOrderNumberGenerator generator=new FleetPoolOrderNumberGenerator(); + @Test void existingLooseboardAndTruckFormatLocationAndSuffixArePreserved(){ + String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + assertThat(generator.next("LOOSEBOARD","FBA:ONT8",1234567)).isEqualTo("LB-ONT-"+day+"-234567"); + assertThat(generator.next("TRUCK","nyc",1000001)).isEqualTo("TR-NYC-"+day+"-000001"); + } + @Test void existingPrivateDestinationAndShortCodeRulesArePreserved(){ + String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + assertThat(generator.next("TRUCK","private address",9)).isEqualTo("TR-WH-"+day+"-000009"); + assertThat(generator.next("LOOSEBOARD","ab12",9)).isEqualTo("LB-AB12-"+day+"-000009"); + assertThat(generator.next("TRUCK",null,-1)).isEqualTo("TR-WH-"+day+"-999999"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index df073ba1f..e75881c74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -69,7 +69,7 @@ class FbaAppointmentPersistenceTest { new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓","FBA:ONT8","ONT8",secondPackages,"ORDER:1",true,List.of())); } private static SourceFbaAppointmentRequest request(String key) { - return new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8")), + return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2","FBA:ONT8")), "100","ONT8",new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),key); } @Test void failureAfterHeaderAndFirstItemRollsBackTheCommandAndEntireAggregate() { @@ -169,7 +169,7 @@ class FbaAppointmentPersistenceTest { var service=context.getBean(InboundFbaAppointmentService.class);var linker=context.getBean(AppointmentSourceLinker.class); var before=new AppointmentSourceService.Source("BOOKING:B-1","C",null,null,"B-1",null,"100","美国仓","FBA:ONT8","ONT8",null,"BOOKING:1",true,List.of()); when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(before)); - var request=new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming"); + var request=new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming"); var saved=service.createFromSources("operator",request); assertNull(saved.items().get(0).orderId()); assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->linker.materialized("C","B-1",100L)); @@ -221,7 +221,7 @@ class FbaAppointmentPersistenceTest { @Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);} @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} - @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new AppointmentSourceLinker(orders,items,bookings);} + @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} @Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();} } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 572a140cf..08f995e1a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -55,15 +55,26 @@ class InboundFbaAppointmentServiceTest { List refs(String... keys) {return Arrays.stream(keys).map(key->new AppointmentCoverageService.SourceRef(cargo.get(key).customerId(),key,cargo.get(key).destinationKey())).toList();} FbaAppointmentDetails pending(String remark) {return new FbaAppointmentDetails(LocalDate.of(2026,10,15),null,null,null,null,null,"PENDING",remark,null,null);} FbaAppointmentDetails confirmed() {return new FbaAppointmentDetails(LocalDate.of(2026,10,14),null,null,"EXT-ONT8-1",at,at.plusHours(1),"SUCCESS","已核实",null,null);} - SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(refs(sources),"100","ONT8",details,key);} + List requestRefs(String... keys) {return refs(keys).stream().map(r->new SourceFbaAppointmentRequest.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList();} + SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(requestRefs(sources),"100","ONT8",details,key);} FbaAppointmentResponse create(String key) {return service.createFromSources("operator",request(key,pending(null),"ORDER:1","ORDER:2"));} + @Test void sourceRequestKeepsWireShapeAndPassesAllSourceFieldsToResolution() throws Exception { + var json=new ObjectMapper().findAndRegisterModules(); + var input=request("source-json",pending(null),"ORDER:1","ORDER:2"); + assertEquals(json.valueToTree(refs("ORDER:1","ORDER:2")),json.valueToTree(input).get("sources")); + var decoded=json.readValue(json.writeValueAsString(input),SourceFbaAppointmentRequest.class); + assertEquals(input,decoded); + service.createFromSources("operator",decoded); + verify(sources,times(2)).resolveBatch(refs("ORDER:1","ORDER:2")); + } + @Test void refusesOriginOrDestinationChangedAfterSelection() { var intent=request("origin-changed",pending(null),"ORDER:1"); add("ORDER:1","A",1L,"200","ONT8",10); assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",intent)).getStatusCode().value()); add("ORDER:1","A",1L,"100","LAX9",10); - var changed=new SourceFbaAppointmentRequest(refs("ORDER:1"),"100","ONT8",pending(null),"destination-changed"); + var changed=new SourceFbaAppointmentRequest(requestRefs("ORDER:1"),"100","ONT8",pending(null),"destination-changed"); assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",changed)).getStatusCode().value()); verify(headers,never()).insert(any(InboundFbaAppointment.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java new file mode 100644 index 000000000..fa2a3cefb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java @@ -0,0 +1,111 @@ +package com.baiyue.lastleg.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +class SelfoperatedAnalyticsSchemaContractTest { + private static final Pattern TABLE = Pattern.compile( + "(?s)CREATE TABLE (ll_selfop_\\w+) \\(.*?\\) ENGINE=InnoDB[^;]+;"); + + private static Map tables(String sql) { + var result = new LinkedHashMap(); + var matcher = TABLE.matcher(sql); + while (matcher.find()) { + assertNull(result.put(matcher.group(1), matcher.group()), "duplicate table definition"); + } + return result; + } + + @Test + void sourceAndEmptyDatabaseBaselineContainIdenticalTenIndependentTables() throws Exception { + var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))); + assertEquals(10, source.size()); + assertEquals(source, deployment); + for (var entry : source.entrySet()) { + var ddl = entry.getValue(); + assertTrue(ddl.contains("COLLATE=utf8mb4_bin"), entry.getKey()); + assertTrue(ddl.contains("id BIGINT NOT NULL")); + assertTrue(ddl.contains("created_at DATETIME(3) NOT NULL")); + assertTrue(ddl.contains("updated_at DATETIME(3) NOT NULL")); + assertFalse(ddl.contains("AUTO_INCREMENT")); + assertFalse(ddl.contains("tenant_id")); + assertFalse(ddl.contains("FOREIGN KEY")); + } + } + + @Test + void unknownSourceTimeAndStockCannotBecomeZeroOrEligibleSalesByDefault() throws Exception { + var ddl = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + for (var table : new String[]{"ll_selfop_sync_scope", "ll_selfop_outbound_order"}) { + assertTrue(ddl.get(table).contains("source_timezone VARCHAR(64) NULL COMMENT")); + } + var stock = ddl.get("ll_selfop_inventory_snapshot"); + for (var column : new String[]{"physical_quantity", "available_quantity", "frozen_quantity", "transit_quantity"}) { + assertTrue(stock.contains(column + " DECIMAL(20,6) NULL COMMENT")); + } + assertTrue(ddl.get("ll_selfop_outbound_order").contains("sales_eligible TINYINT NOT NULL DEFAULT 0")); + assertTrue(ddl.get("ll_selfop_outbound_order").contains("source_status = 100 AND source_timezone IS NOT NULL")); + assertTrue(ddl.get("ll_selfop_outbound_item").contains("CHECK (actual_quantity >= 0)")); + // Negative observed stock must be retained as INVALID, never rejected/coerced by a quantity CHECK. + assertFalse(stock.contains("CHECK (available_quantity")); + } + + @Test + void durableRecoveryAndImmutableVersionKeysArePresent() throws Exception { + var ddl = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + var task = ddl.get("ll_selfop_sync_task"); + for (var column : new String[]{"apply_state", "affected_warehouse_codes_json", "observation_baseline", + "routing_manifest_json", "request_hash", "apply_completed_at_utc", "task_no", "execution_purpose", + "validation_root_task_id", "validation_budget_json", "result_published_run_id", "progress_json", + "progress_updated_at_utc"}) { + assertTrue(task.contains(" " + column + " "), column); + } + assertTrue(task.contains("UNIQUE KEY uk_selfop_task_no (task_no)")); + var account = ddl.get("ll_selfop_outbound_discovery_scope"); + for (var column : new String[]{"api_budget_json", "api_cooldown_until_utc", "api_pause_reason"}) { + assertTrue(account.contains(" " + column + " "), column); + } + var run = ddl.get("ll_selfop_sync_run"); + for (var column : new String[]{"execution_purpose", "build_started_at_utc", "superseded_at_utc", + "progress_snapshot_json", "heartbeat_at_utc", "inventory_daily_sample_date", "inventory_sample_rule_version"}) { + assertTrue(run.contains(" " + column + " "), column); + } + assertTrue(run.contains("uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date)")); + assertTrue(run.contains("CONSTRAINT chk_selfop_inventory_daily CHECK")); + assertTrue(ddl.get("ll_selfop_sku_daily_sales").contains("idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code)")); + assertTrue(ddl.get("ll_selfop_outbound_order").contains("idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id)")); + assertTrue(ddl.get("ll_selfop_sync_stage").contains("stage_batch_id,object_type,object_key_hash")); + assertTrue(ddl.get("ll_selfop_sync_stage").contains("observation_sequence IS NOT NULL")); + assertTrue(ddl.get("ll_selfop_outbound_order").contains("source_account,customer_code,delivery_no")); + assertTrue(ddl.get("ll_selfop_sku_daily_sales").contains("sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date")); + assertTrue(ddl.get("ll_selfop_sku_profile").contains("profile_run_id,source_account,customer_code,warehouse_code,sku_code")); + assertTrue(ddl.get("ll_selfop_inventory_snapshot").contains("snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality")); + } + + @Test + void readAndSyncPermissionsAreIndependentWithThreeAuthorizedAdminMenus() throws Exception { + var seed = Files.readString(Path.of("../iam/src/main/resources/db/selfoperated-analytics-permissions.sql")); + var unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertTrue(unified.contains(seed.strip())); + assertTrue(seed.contains("platform:self-operated-analytics:list")); + assertTrue(seed.contains("platform:self-operated-analytics:sync")); + for (var route : new String[]{"/self-operated/sales", "/self-operated/inventory", "/self-operated/collection"}) { + assertTrue(seed.contains(route)); + } + assertTrue(seed.contains("'API',5118100,'platform:self-operated-analytics:list'")); + assertTrue(seed.contains("'API',5118130,'platform:self-operated-analytics:sync'")); + assertFalse(seed.contains("'MERCHANT'")); + assertFalse(seed.contains("INSERT IGNORE")); + assertFalse(seed.contains("ON DUPLICATE KEY")); + assertTrue(seed.contains("(4000001,5118000,0,")); + assertTrue(seed.contains("(4000001,5118001,0,")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java new file mode 100644 index 000000000..7e9208102 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java @@ -0,0 +1,290 @@ +package com.baiyue.lastleg.selfoperated.analytics.client; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties; +import org.junit.jupiter.api.Test; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import static org.mockito.Mockito.*; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import java.time.LocalDateTime; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +class RestLingxingAnalyticsClientTest { + private final SelfopAnalyticsProperties properties = new SelfopAnalyticsProperties(); + private final RestClient.Builder builder = RestClient.builder().baseUrl("http://synthetic-integration"); + private final MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + private final SelfopEgressService egress = mock(SelfopEgressService.class); + private final RestLingxingAnalyticsClient client = new RestLingxingAnalyticsClient(builder.build(), properties, egress); + private static SelfopRequestContext context(String account) { return new SelfopRequestContext(account,"BUSINESS",1L,"selfop-1","grant",1L,2L); } + private static final String PREFIX = "http://synthetic-integration/internal/integrations/lingxing/wms"; + + private void enable() { + + properties.setCollectionMode("BUSINESS"); + when(egress.reserve(any(),any(),anyInt(),any(),any(),any())).thenAnswer(i -> { + SelfopRequestContext c=i.getArgument(0); + return new SelfopEgressDtos.EgressContext(c.sourceAccount(),c.executionPurpose(),"1",c.sessionId(),"synthetic-attempt",c.exclusiveGrantId()); + }); + properties.setSourceAccount("synthetic-account"); + properties.setInternalServiceToken("synthetic-token"); + } + @Test void disabledByDefaultAndAccountBindingFailBeforeTransport() { + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("EXECUTION_DISABLED"); + enable(); + assertThatThrownBy(() -> client.warehouses(context("other-account"), 1, 100)).hasMessage("SOURCE_ACCOUNT_MISMATCH"); + properties.setInternalServiceToken(""); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("INTERNAL_TOKEN_UNCONFIGURED"); + server.verify(); + } + @Test void oneWarehousePagePreservesTimezoneAsClueAndStringLongMetadata() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":"1","records":[ + {"whCode":"WH-A","whNameCn":"A","warehouseType":1,"status":0,"timeZone":"America/New_York"}]}} + """); + var page = client.warehouses(context("synthetic-account"), 1, 100); + assertThat(page.total()).isEqualTo(1L); + assertThat(page.records().get(0).timeZone()).isEqualTo("America/New_York"); + assertThat(properties.getSourceQueryTimezone()).isNull(); + server.verify(); + } + @Test void discoveryUsesNormalizedEmptyMetadataAndDoesNotSendWarehouseFilter() { + enable(); + expect("/outbound-orders/query", """ + {"startTime":"2026-10-01T00:00:00","endTime":"2026-10-02T00:00:00","current":1,"size":100} + """, "{\"code\":\"200\",\"data\":{\"current\":1,\"size\":100,\"pages\":0,\"total\":\"0\",\"records\":[]}}"); + assertThat(client.discover(context("synthetic-account"), LocalDateTime.of(2026,10,1,0,0), + LocalDateTime.of(2026,10,2,0,0), 1, 100).records()).isEmpty(); + server.verify(); + } + @Test void locateHasNoTimeOrStatusAndReturnsCurrentCancelledIdentity() { + enable(); + expect("/outbound-orders/locate", "{\"deliveryNo\":\"DO-1\",\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[ + {"deliveryNo":"DO-1","sourceNo":"SRC-1","customerCode":"C","whCode":"WH-B","status":99,"outboundTime":null}]}} + """); + var summary = client.locate(context("synthetic-account"), "DO-1", 1, 100).records().get(0); + assertThat(summary.sourceStatus()).isEqualTo(99); + assertThat(summary.warehouseCode()).isEqualTo("WH-B"); + server.verify(); + } + @Test void detailProjectsActualLinesOnlyAndDoesNotInventUnitOrTimestamp() { + enable(); + expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """ + {"code":"200","data":{"deliveryNo":"DO-1","customerCode":"C","whCode":"WH-A","status":100, + "orderCreateTime":"2025-01-01 00:00:00","outboundTime":"2026-10-01 00:00:00", + "skuVOList":[{"productSku":"sku","actualDeliveryQty":2.125,"deliveryQty":99}, + {"productSku":"sku","actualDeliveryQty":3}], + "packageList":[{"skuList":[{"actualDeliveryQty":"5.125"}]}]}} + """); + var order = client.detail(context("synthetic-account"), "WH-A", "C", "DO-1"); + assertThat(order.items()).hasSize(2); + assertThat(order.items().get(0).actualQuantity()).isEqualByComparingTo("2.125"); + assertThat(order.items().get(0).quantityUnit()).isNull(); + assertThat(order.sourceCreatedTime()).isEqualTo("2025-01-01 00:00:00"); + server.verify(); + } + @Test void inventoryPreservesUnknownNegativeAndAllQualitiesWithoutInventedArithmetic() { + enable(); + expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":1000}", """ + {"code":"200","data":{"current":1,"size":1000,"pages":1,"total":1,"records":[ + {"customerCode":"C","whCode":"WH-A","productSku":"sku","productQuality":1, + "stkQty":120,"freezeQty":20,"useStkQty":100.125,"transitQty":-3}]}} + """); + var row = client.inventory(context("synthetic-account"), "WH-A", 1, 1000).records().get(0); + assertThat(row.productQuality()).isEqualTo(1); + assertThat(row.availableQuantity()).isEqualByComparingTo("100.125"); + assertThat(row.transitQuantity()).isEqualByComparingTo("-3"); + server.verify(); + } + @Test void missingPageMetadataCannotBeInterpretedAsEmptySuccess() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", + "{\"code\":\"200\",\"data\":{\"records\":[]}}"); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).isInstanceOf(AnalyticsClientException.class); + server.verify(); + } + @Test void unknownStockIsNotZeroAndDecimalPrecisionIsRetained() { + enable(); + expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[ + {"customerCode":"C","whCode":"WH-A","productSku":"sku","productQuality":0, + "stkQty":99999999999999.123456,"freezeQty":"","useStkQty":null,"transitQty":0}]}} + """); + var stock = client.inventory(context("synthetic-account"), "WH-A", 1, 100).records().get(0); + assertThat(stock.physicalQuantity()).isEqualByComparingTo("99999999999999.123456"); + assertThat(stock.frozenQuantity()).isNull(); + assertThat(stock.availableQuantity()).isNull(); + assertThat(stock.transitQuantity()).isEqualByComparingTo("0"); + server.verify(); + } + @Test void detailIdentityMismatchIsNotTreatedAsCancellationOrZero() { + enable(); + expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """ + {"code":"200","data":{"deliveryNo":"DO-2","customerCode":"C","whCode":"WH-A","status":99,"skuVOList":[]}} + """); + assertThatThrownBy(() -> client.detail(context("synthetic-account"), "WH-A", "C", "DO-1")) + .hasMessage("DETAIL_IDENTITY_MISMATCH"); + server.verify(); + } + @Test void scopeLeakIsRejected() { + enable(); + expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[ + {"customerCode":"C","whCode":"WH-B","productSku":"sku","productQuality":0}]}} + """); + assertThatThrownBy(() -> client.inventory(context("synthetic-account"), "WH-A", 1, 100)).hasMessage("INVENTORY_WAREHOUSE_MISMATCH"); + server.verify(); + } + @Test void errorsKeepRetryabilityWithoutRawProviderText() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", """ + {"code":"BIZ_503","message":"sensitive source response","data":{"retryable":true}} + """); + server.expect(requestTo(PREFIX + "/warehouses/query")).andRespond(withStatus(HttpStatus.UNAUTHORIZED) + .body("sensitive token response")); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage("INTEGRATION_PROVIDER_FAILURE") + .satisfies(e -> assertThat(((AnalyticsClientException)e).retryable()).isTrue()); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage("INTEGRATION_HTTP_FAILURE") + .satisfies(e -> assertThat(((AnalyticsClientException)e).retryable()).isFalse()); + server.verify(); + } + @Test void knownErrorsPreserveSafeClassificationAndCredentialFailuresNeverRetry() { + enable(); + String[] codes = {"LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED", + "LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE"}; + boolean[] upstreamRetryable = {true, true, false, true}; + boolean[] expectedRetryable = {false, false, false, true}; + for (int i = 0; i < codes.length; i++) { + expect("/warehouses/query", "{\"current\":1,\"size\":100}", + "{\"code\":\"BIZ_503\",\"message\":\"secret raw provider message\",\"data\":{\"errorCode\":\"" + + codes[i] + "\",\"retryable\":" + upstreamRetryable[i] + "}}"); + } + for (int i = 0; i < codes.length; i++) { + String code = codes[i]; boolean retryable = expectedRetryable[i]; + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage(code).hasNoCause() + .satisfies(e -> { + assertThat(((AnalyticsClientException)e).code()).isEqualTo(code); + assertThat(((AnalyticsClientException)e).retryable()).isEqualTo(retryable); + }); + } + server.verify(); + } + @Test void unrecognizedProviderCodeCannotLeakThroughDiagnosticMessage() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", """ + {"code":"BIZ_400","message":"sensitive source response", + "data":{"errorCode":"sensitive arbitrary provider code","retryable":false}} + """); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage("INTEGRATION_PROVIDER_FAILURE").hasNoCause(); + server.verify(); + } + @Test void durableDenialPreservesSampleStopAndQueueDeadline() { + enable(); + expect("/warehouses/query","{\"current\":1,\"size\":100}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\"LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED\",\"denial\":{\"errorCode\":\"ACCOUNT_INTERVAL_WAIT\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:00:05Z\"}}}"); + expect("/warehouses/query","{\"current\":1,\"size\":100}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\"LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED\",\"denial\":{\"errorCode\":\"VALIDATION_DEADLINE\",\"sampleDone\":true,\"waitUntil\":null}}}"); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,100)).isInstanceOf(EgressRejectedException.class).satisfies(e->assertThat(((EgressRejectedException)e).waitUntilUtc()).isEqualTo(LocalDateTime.of(2026,10,12,0,0,5))); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,100)).isInstanceOf(EgressRejectedException.class).satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isTrue());server.verify(); + } + @Test void allFiveEndpointsSendOnlyTheCurrentControlHeaderOverLoopbackHttp() throws Exception { + enable();properties.setEnvironment("production"); + String control="{\"sourceAccount\":\"synthetic-account\",\"executionPurpose\":\"BUSINESS\",\"rootTaskId\":\"1\",\"sessionId\":\"selfop-1\",\"attemptId\":\"synthetic-attempt\",\"exclusiveGrantId\":\"grant\"}"; + var received=new java.util.concurrent.ConcurrentHashMap(); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + var wrongHeaders=new java.util.concurrent.atomic.AtomicInteger(); + var loopback=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0); + loopback.createContext("/internal/integrations/lingxing/wms",exchange->{ + calls.incrementAndGet();String path=exchange.getRequestURI().getPath(); + String actual=exchange.getRequestHeaders().getFirst("X-Lingxing-Egress-Context"); + if(exchange.getRequestHeaders().containsKey("X-Lingxing-Egress-SelfopRequestContext"))wrongHeaders.incrementAndGet(); + boolean accepted=control.equals(actual)&&"synthetic-token".equals(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")) + &&"POST".equals(exchange.getRequestMethod()); + if(actual!=null)received.put(path,actual); + exchange.getRequestBody().readAllBytes(); + String data; + if(path.endsWith("/outbound-orders/detail")) { + data="{\"deliveryNo\":\"D\",\"customerCode\":\"C\",\"whCode\":\"A\",\"status\":100,\"skuVOList\":[{\"productSku\":\"SKU\",\"actualDeliveryQty\":1}]}"; + } else { + String row=path.endsWith("/warehouses/query") + ?"{\"whCode\":\"A\",\"whNameCn\":\"Synthetic\",\"warehouseType\":0,\"status\":0}" + :path.endsWith("/product-inventory/query") + ?"{\"whCode\":\"A\",\"customerCode\":\"C\",\"productSku\":\"SKU\",\"productQuality\":0,\"stkQty\":1,\"useStkQty\":1,\"freezeQty\":0,\"transitQty\":0}" + :"{\"whCode\":\"A\",\"customerCode\":\"C\",\"deliveryNo\":\"D\",\"status\":100}"; + data="{\"current\":1,\"size\":1,\"pages\":1,\"total\":\"1\",\"records\":["+row+"]}"; + } + byte[] response=("{\"code\":\"200\",\"data\":"+data+"}").getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type","application/json"); + exchange.sendResponseHeaders(accepted?200:403,response.length); + exchange.getResponseBody().write(response);exchange.close(); + }); + loopback.start(); + try { + properties.setIntegrationBaseUrl("http://127.0.0.1:"+loopback.getAddress().getPort()); + var httpClient=new RestLingxingAnalyticsClient(RestClient.builder(),properties,egress); + var ctx=context("synthetic-account"); + assertThat(httpClient.warehouses(ctx,1,1).records()).hasSize(1); + assertThat(httpClient.discover(ctx,LocalDateTime.of(2026,10,1,0,0),LocalDateTime.of(2026,10,2,0,0),1,1).records()).hasSize(1); + assertThat(httpClient.locate(ctx,"D",1,1).records()).hasSize(1); + assertThat(httpClient.detail(ctx,"A","C","D").items()).hasSize(1); + assertThat(httpClient.inventory(ctx,"A",1,1).records()).hasSize(1); + assertThat(calls.get()).isEqualTo(5);assertThat(wrongHeaders.get()).isZero(); + assertThat(received).hasSize(5);assertThat(received.values()).containsOnly(control); + verify(egress,never()).pauseUncertain(any(),any(),any()); + } finally {loopback.stop(0);} + } + @Test void unconfirmedCompletionResponsePersistsPauseForItsReservedAttempt() { + enable(); + expect("/warehouses/query","{\"current\":1,\"size\":1}",""" + {"code":"B0001","data":{"errorCode":"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED","retryable":false, + "egress":{"attemptId":"synthetic-attempt","actualHttpAttempts":1,"accountPaused":true,"cooldownUntil":null}}} + """); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + verify(egress).pauseUncertain(argThat(c->"synthetic-account".equals(c.sourceAccount())&&"synthetic-attempt".equals(c.attemptId())), + eq(SelfopEgressDtos.Operation.WAREHOUSES),eq("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"));server.verify(); + } + @Test void wrongResponseAttemptCannotPersistPauseForTheCurrentReservation() { + enable(); + expect("/warehouses/query","{\"current\":1,\"size\":1}",""" + {"code":"B0001","data":{"errorCode":"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED", + "egress":{"attemptId":"another-attempt","actualHttpAttempts":1,"accountPaused":true}}} + """); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("INTEGRATION_PAUSE_BINDING_INVALID"); + verify(egress,never()).pauseUncertain(any(),any(),any());server.verify(); + } + @Test void transportResponseLossPersistsPauseWithoutAnotherHttpAttempt() { + enable(); + server.expect(requestTo(PREFIX+"/warehouses/query")).andRespond(request->{throw new org.springframework.web.client.ResourceAccessException("synthetic reply lost");}); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("INTEGRATION_TRANSPORT_FAILURE"); + verify(egress).pauseUncertain(any(),eq(SelfopEgressDtos.Operation.WAREHOUSES),eq("INTEGRATION_TRANSPORT_FAILURE")); + verify(egress,times(1)).reserve(any(),any(),anyInt(),any(),any(),any());server.verify(); + } + @Test void rateAndUnconfirmedAckRemainDifferentSignalsToTheDurableAccount() { + enable(); + String[] codes={"LINGXING_WMS_HTTP_RATE_LIMITED","LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"}; + for(String code:codes){ + expect("/warehouses/query","{\"current\":1,\"size\":1}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\""+code+"\",\"retryable\":false,\"egress\":{\"attemptId\":\"synthetic-attempt\",\"actualHttpAttempts\":1,\"accountPaused\":true}}}"); + } + for(String code:codes){ + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage(code); + verify(egress).pauseUncertain(any(),eq(SelfopEgressDtos.Operation.WAREHOUSES),eq(code)); + } + server.verify(); + } + private void expect(String path, String request, String response) { + server.expect(requestTo(PREFIX + path)).andExpect(method(HttpMethod.POST)) + .andExpect(header("X-Internal-Service-Token", "synthetic-token")) + .andExpect(header("X-Lingxing-Egress-Context", "{\"sourceAccount\":\"synthetic-account\",\"executionPurpose\":\"BUSINESS\",\"rootTaskId\":\"1\",\"sessionId\":\"selfop-1\",\"attemptId\":\"synthetic-attempt\",\"exclusiveGrantId\":\"grant\"}")) + .andExpect(content().json(request, true)) + .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java new file mode 100644 index 000000000..fee983548 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.query.*; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.*; +import org.springframework.web.server.ResponseStatusException; +import org.mockito.ArgumentCaptor; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformAnalyticsControllerTest { + private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));} + @Test void rejectsCustomerEvenWithReadPermission(){ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var controller=new PlatformAnalyticsController(service,collection); + var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION)); + assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null)); + assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null)); + verifyNoInteractions(service,collection); + } + @Test void writePermissionAloneDoesNotAuthorizeAnyRead(){ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var controller=new PlatformAnalyticsController(service,collection); + var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); + assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null)); + assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null)); + assertThrows(ResponseStatusException.class,()->controller.slowSkus(null,null)); + verifyNoInteractions(service,collection); + } + @Test void allElevenReadEndpointsShareTheIndependentGuard(){ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var c=new PlatformAnalyticsController(service,collection);var user=platform(); + c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null); + c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null); + verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null); + verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null); + verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null); + assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformAnalyticsController.class.getAnnotation(PreAuthorize.class).value()); + } + private static HandlerMethodArgumentResolver identity(){ + return new HandlerMethodArgumentResolver(){ + public boolean supportsParameter(MethodParameter p){return p.getParameterType()==AuthenticatedUser.class;} + public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory b){return platform();} + }; + } + @Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary") + .param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","CUSTOM").param("startDate","2026-10-01") + .param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22") + .param("salesScopeHash","a".repeat(64)).param("inventoryScopeHash","b".repeat(64)) + .param("queryAsOf","2026-10-12T04:00:00Z")).andExpect(status().isOk()); + var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture()); + assertEquals(LocalDate.of(2026,10,1),cap.getValue().startDate());assertEquals(Instant.parse("2026-10-12T04:00:00Z"),cap.getValue().queryAsOf()); + assertEquals(11L,cap.getValue().salesRunId());assertEquals(2,cap.getValue().current()); + } + @Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/LXCOL-DEMO") + .param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk()); + var cap=ArgumentCaptor.forClass(CollectionQuery.class);verify(collection).taskDetail(eq("LXCOL-DEMO"),cap.capture()); + assertEquals("W",cap.getValue().warehouseCode());verifyNoInteractions(service); + } + @Test void inventoryHttpBindsAttentionSortAndThresholdIndependentlyFromVersionIdentity()throws Exception{ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage").param("sourceAccount","demo").param("warehouseCode","W") + .param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")).andExpect(status().isOk()); + var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class); + verify(service).inventoryCoverage(q.capture(),options.capture());assertEquals("C",q.getValue().customerCode()); + assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold()); + verifyNoInteractions(collection); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java new file mode 100644 index 000000000..f11428b44 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java @@ -0,0 +1,111 @@ +package com.baiyue.lastleg.selfoperated.analytics.inventory; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +class InventorySnapshotAssemblerTest { + private final LocalDateTime time = LocalDateTime.of(2026, 10, 12, 0, 0); + private SelfopSyncScope scope() { + var scope = new SelfopSyncScope(); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); return scope; + } + private SelfopSyncRun run(long count) { + var run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setId(10L); run.setSourceTotalCount(count); run.setUniqueObjectCount(count); + run.setPageCount(1); run.setCoverageStatus("COMPLETE"); run.setFetchStartedAtUtc(time); run.setFetchFinishedAtUtc(time.plusMinutes(1)); + run.setBasePublishedRunId(9L); run.setReferenceSalesRunId(8L); return run; + } + private SelfopPayloads.Stock stock(String customer, String sku, Integer quality, BigDecimal available) { + return new SelfopPayloads.Stock("demo", customer, "A", sku, quality, "Name", new BigDecimal("120"), + available, new BigDecimal("20"), new BigDecimal("30"), null); + } + private SelfopSyncStage stage(SelfopPayloads.Stock stock) { + var stage = new SelfopSyncStage(); stage.setRunId(10L); stage.setStageBatchId(10L); stage.setLastFetchRunId(10L); + stage.setWarehouseCode(stock.warehouseCode()); stage.setObjectType("PRODUCT_STOCK"); stage.setValidationStatus("VALID"); + stage.setFirstPageNo(1); stage.setObservedAtUtc(time); stage.setPayloadJson(json(stock)); stage.setContentHash(hash(stock)); + stage.setObjectKeyHash(hash(List.of(stock.sourceAccount(), stock.customerCode(), stock.warehouseCode(), stock.skuCode(), stock.productQuality()))); + return stage; + } + private InventorySnapshotAssembler.Candidate build(SelfopSyncRun run, List stages, + List previous, List profiles, List sales) { + return InventorySnapshotAssembler.assemble(scope(), run, stages, previous, profiles, sales, "PCS"); + } + @Test void quantitiesRemainIndependentAndAllQualitiesKeepTheirIdentity() { + var rows = List.of(stage(stock("c", "sku", 0, new BigDecimal("100"))), + stage(stock("c", "sku", 1, new BigDecimal("8"))), stage(stock("c", "sku2", 0, BigDecimal.ZERO))); + var candidate = build(run(3), rows, List.of(), List.of(), List.of()); + assertEquals(3, candidate.snapshots().size()); assertEquals(2, candidate.profiles().size()); + var regular = candidate.snapshots().get(0); + assertEquals(new BigDecimal("100"), regular.getAvailableQuantity()); + assertEquals(new BigDecimal("30"), regular.getTransitQuantity()); + assertEquals("VALID", regular.getQualityStatus()); + assertEquals(BigDecimal.ZERO, candidate.snapshots().get(2).getAvailableQuantity()); + } + @Test void missingAndNegativeQuantitiesNeverTurnIntoZero() { + var candidate = build(run(2), List.of(stage(stock("c", "unknown", 0, null)), + stage(stock("c", "negative", 0, BigDecimal.ONE.negate()))), List.of(), List.of(), List.of()); + assertEquals("UNKNOWN", candidate.snapshots().get(0).getQualityStatus()); + assertNull(candidate.snapshots().get(0).getAvailableQuantity()); + assertEquals("INVALID", candidate.snapshots().get(1).getQualityStatus()); + assertEquals(BigDecimal.ONE.negate(), candidate.snapshots().get(1).getAvailableQuantity()); + } + @Test void unrepresentableDecimalsArePreservedInStageAndCannotBeRoundedInSnapshot() { + var source = stage(stock("c", "precise", 0, new BigDecimal("0.1234567"))); + var candidate = build(run(1), List.of(source), List.of(), List.of(), List.of()); + assertEquals("QUANTITY_PRECISION", candidate.snapshots().get(0).getQualityReason()); + assertNull(candidate.snapshots().get(0).getAvailableQuantity()); + assertEquals(new BigDecimal("0.1234567"), read(source.getPayloadJson(), SelfopPayloads.Stock.class).availableQuantity()); + } + @Test void total5741Unique5740RejectsEvenIfRowCountMatchesSourceTotal() { + var run = run(5741); run.setUniqueObjectCount(5740L); run.setPageCount(6); + var rows = new ArrayList(); + for (int i = 0; i < 5741; i++) rows.add(stage(stock("c", "sku" + (i % 5740), 0, BigDecimal.ONE))); + assertThrows(IllegalStateException.class, () -> build(run, rows, List.of(), List.of(), List.of())); + } + @Test void duplicateBusinessIdentityRejectedEvenIfDistinctStageHashesClaimComplete() { + var first = stage(stock("c", "sku", 0, BigDecimal.ONE)); + var second = stage(stock("c", "sku", 0, BigDecimal.TEN)); second.setObjectKeyHash("different"); + assertThrows(IllegalStateException.class, () -> build(run(2), List.of(first, second), List.of(), List.of(), List.of())); + } + @Test void newAttemptCannotBorrowOldAttemptPages() { + var stage = stage(stock("c", "sku", 0, BigDecimal.ONE)); stage.setStageBatchId(9L); + assertThrows(IllegalStateException.class, () -> build(run(1), List.of(stage), List.of(), List.of(), List.of())); + } + @Test void warehouseLeakOrTamperedContentRejected() { + var stock = stock("c", "sku", 0, BigDecimal.ONE); + var wrong = new SelfopPayloads.Stock(stock.sourceAccount(), stock.customerCode(), "B", stock.skuCode(), 0, + null, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null); + assertThrows(IllegalStateException.class, () -> build(run(1), List.of(stage(wrong)), List.of(), List.of(), List.of())); + var tampered = stage(stock); tampered.setContentHash("bad"); + assertThrows(IllegalStateException.class, () -> build(run(1), List.of(tampered), List.of(), List.of(), List.of())); + } + @Test void fixedReferencesGenerateUnknownAndSalesCannotCreateInventoryEvidence() { + var previous = new SelfopInventorySnapshot(); previous.setSnapshotRunId(9L); previous.setSourceAccount("demo"); + previous.setWarehouseCode("A"); previous.setCustomerCode("c"); previous.setSkuCode("old"); previous.setProductQuality(1); + previous.setAvailableQuantity(BigDecimal.TEN); + var old = profile(9L, "old"); old.setEvidenceType("INVENTORY_OBSERVATION"); old.setFirstSeenAtUtc(time.minusDays(3)); + old.setEarliestEvidenceAtUtc(time.minusDays(3)); old.setEvidenceRunId(1L); old.setLastSeenRunId(9L); + var sales = profile(8L, "salesOnly"); sales.setEvidenceType("VALID_OUTBOUND"); sales.setEarliestEvidenceAtUtc(time.minusDays(100)); + var candidate = build(run(0), List.of(), List.of(previous), List.of(old), List.of(sales)); + assertEquals(2, candidate.snapshots().size()); + assertTrue(candidate.snapshots().stream().allMatch(s -> "UNKNOWN".equals(s.getQualityStatus()) && s.getAvailableQuantity() == null)); + var inherited = candidate.profiles().stream().filter(p -> "old".equals(p.getSkuCode())).findFirst().orElseThrow(); + assertEquals(time.minusDays(3), inherited.getFirstSeenAtUtc()); assertEquals(9L, inherited.getLastSeenRunId()); + var placeholder = candidate.profiles().stream().filter(p -> "salesOnly".equals(p.getSkuCode())).findFirst().orElseThrow(); + assertEquals("NONE", placeholder.getEvidenceType()); assertNull(placeholder.getEarliestEvidenceAtUtc()); + assertNull(placeholder.getEvidenceRunId()); assertNull(placeholder.getQuantityUnit()); + assertEquals(BigDecimal.TEN, previous.getAvailableQuantity()); assertEquals(9L, old.getProfileRunId()); + } + @Test void inventoryFirstSeenIsInheritedOnlyFromInventoryNotSales() { + var old = profile(9L, "sku"); old.setEvidenceType("NONE"); old.setFirstSeenAtUtc(time.minusDays(90)); + var candidate = build(run(1), List.of(stage(stock("c", "sku", 0, BigDecimal.ONE))), List.of(), List.of(old), List.of()); + assertEquals(time, candidate.profiles().get(0).getFirstSeenAtUtc()); + } + private SelfopSkuProfile profile(long runId, String sku) { + var profile = new SelfopSkuProfile(); profile.setProfileRunId(runId); profile.setSourceAccount("demo"); + profile.setWarehouseCode("A"); profile.setCustomerCode("c"); profile.setSkuCode(sku); return profile; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java new file mode 100644 index 000000000..6f3214f32 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java @@ -0,0 +1,256 @@ +package com.baiyue.lastleg.selfoperated.analytics.inventory; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class InventorySnapshotServiceTest { + private final SelfopCoordinationMapper coordination = mock(SelfopCoordinationMapper.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopInventorySnapshotMapper snapshots = mock(SelfopInventorySnapshotMapper.class); + private final SelfopSkuProfileMapper profiles = mock(SelfopSkuProfileMapper.class); + private final SelfopAnalyticsProperties properties = new SelfopAnalyticsProperties(); + private final InventorySnapshotService service = new InventorySnapshotService(coordination, scopes, runs, tasks, stages, snapshots, profiles, properties); + private SelfopSyncScope scope; + private SelfopSyncRun run; + private SelfopSyncTask task; + @BeforeEach void prepare() { + scope = new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); + scope.setDataFamily("INVENTORY"); scope.setScopeHash("hash"); scope.setLeaseOwner("owner"); + scope.setLeaseUntilUtc(LocalDateTime.now(java.time.ZoneOffset.UTC).plusHours(1)); scope.setFenceToken(4L); + scope.setRebuildRequired(1); scope.setFactRevision(9L); + run = new SelfopSyncRun(); run.setId(10L); run.setTaskId(20L); run.setScopeId(1L); + run.setSourceAccount("demo"); run.setWarehouseCode("A"); run.setRunRole("WAREHOUSE_SYNC"); + run.setExecutionPurpose("BUSINESS"); run.setScopeHash("hash"); run.setFenceToken(4L); run.setStatus("READY"); run.setCoverageStatus("COMPLETE"); + run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); + run.setSourceTotalCount(0L); run.setUniqueObjectCount(0L); run.setPageCount(1); + run.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 0, 0)); run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc().plusMinutes(1)); + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setId(20L); task.setScopeId(1L); task.setScopeHash("hash"); + task.setSourceAccount("demo"); task.setWarehouseCode("A"); task.setApplyState("NOT_APPLICABLE"); + when(coordination.lockScope(1L)).thenReturn(scope); when(coordination.lockRun(10L)).thenReturn(run); + when(coordination.lockTask(20L)).thenReturn(task); + when(stages.selectList(any())).thenReturn(List.of()); when(snapshots.selectList(any())).thenReturn(List.of()); + when(profiles.selectList(any())).thenReturn(List.of()); + properties.setQuantityUnit("PCS"); + } + @Test void inventoryPublicationDoesNotConsultSalesBarrierOrModifyOutboundRevision() { + when(coordination.barriers("demo", "A")).thenReturn(1L); + service.publish(1L, 10L, "owner", 4L); + assertEquals(10L, scope.getPublishedRunId()); assertEquals("PUBLISHED", run.getStatus()); + assertEquals(1, scope.getRebuildRequired()); assertEquals(9L, scope.getFactRevision()); + verify(coordination, never()).barriers(anyString(), anyString()); + verify(tasks).updateById(argThat((SelfopSyncTask t) -> "SUCCEEDED".equals(t.getStatus()) && Long.valueOf(10L).equals(t.getResultPublishedRunId()))); + assertEquals(10L, task.getResultPublishedRunId()); + } + @Test void oldFenceOrExpiredLeaseCannotMovePointer() { + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 3L)); + scope.setLeaseUntilUtc(LocalDateTime.now(java.time.ZoneOffset.UTC).minusSeconds(1)); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void newerInventoryPublicationCannotBeOverwrittenByOldCandidate() { + scope.setPublishedRunId(11L); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void unknownUnitFailsClosedAndUnreadyCandidateCannotPublish() { + run.setScopeSnapshotJson("{\"quantityUnit\":null}"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setStatus("FETCHING"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void readyAndPublishedRetryNeverChangesImmutableCandidateRows() { + service.build(1L, 10L, "owner", 4L); + verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class)); + verify(snapshots, never()).insert(anyCollection(), anyInt()); + verify(profiles, never()).insert(any(SelfopSkuProfile.class)); + verify(profiles, never()).insert(anyCollection(), anyInt()); + service.publish(1L, 10L, "owner", 4L); service.publish(1L, 10L, "owner", 4L); + verify(scopes, times(1)).updateById(any(SelfopSyncScope.class)); + } + @Test void malformedCompleteClaimFailsBeforeAnyCandidateIsWritten() { + run.setStatus("VALIDATED"); run.setSourceTotalCount(5741L); run.setUniqueObjectCount(5740L); + assertThrows(IllegalStateException.class, () -> service.build(1L, 10L, "owner", 4L)); + verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class)); + verify(snapshots, never()).insert(anyCollection(), anyInt()); + verify(runs, never()).updateById(any(SelfopSyncRun.class)); + } + @Test void emptyCompleteRunCanBuildAndPublishWithoutInventingSkuEvidence() { + run.setStatus("VALIDATED"); + service.build(1L, 10L, "owner", 4L); + assertEquals("READY", run.getStatus()); verify(runs).updateById(run); + verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class)); + verify(snapshots, never()).insert(anyCollection(), anyInt()); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); + } + @Test void missingProfileRefusesPointerSwitch() { + var row = new SelfopInventorySnapshot(); row.setSnapshotRunId(10L); row.setSourceAccount("demo"); + row.setWarehouseCode("A"); row.setCustomerCode("c"); row.setSkuCode("sku"); + when(snapshots.selectList(any())).thenReturn(List.of(row)); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void validationPurposeCannotBuildOrPublishEvenWhenCompleteAndFenceValid() { + run.setExecutionPurpose("VALIDATION");run.setStatus("VALIDATED"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.build(1L,10L,"owner",4L)).getMessage()); + run.setStatus("READY"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.publish(1L,10L,"owner",4L)).getMessage()); + verify(snapshots, never()).insert(anyCollection(),anyInt());verify(profiles, never()).insert(anyCollection(),anyInt());verify(scopes,never()).updateById(any(SelfopSyncScope.class));assertNull(task.getResultPublishedRunId()); + } + @Test void validationTaskCannotPublishBusinessRunOrAcquireResultLink() { + task.setExecutionPurpose("VALIDATION");task.setValidationRootTaskId(20L); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.publish(1L,10L,"owner",4L)).getMessage()); + verify(scopes,never()).updateById(any(SelfopSyncScope.class));assertNull(task.getResultPublishedRunId()); + } + @Test void candidateBulkInsertHasIdsAndCapsConfiguredBatchSizeAt500() { + run.setStatus("VALIDATED");run.setSourceTotalCount(1L);run.setUniqueObjectCount(1L);properties.setPersistenceBatchSize(1000); + var payload=new SelfopPayloads.Stock("demo","c","A","sku",0,"Name",java.math.BigDecimal.TEN,java.math.BigDecimal.ONE,java.math.BigDecimal.ZERO,java.math.BigDecimal.ZERO,null); + var stage=new SelfopSyncStage();stage.setId(100L);stage.setRunId(10L);stage.setStageBatchId(10L);stage.setLastFetchRunId(10L);stage.setObjectType("PRODUCT_STOCK");stage.setWarehouseCode("A");stage.setValidationStatus("VALID");stage.setFirstPageNo(1);stage.setObservedAtUtc(run.getFetchStartedAtUtc());stage.setPayloadJson(SelfopSupport.json(payload));stage.setContentHash(SelfopSupport.hash(payload));stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","c","A","sku",0))); + when(stages.selectList(any())).thenReturn(List.of(stage));service.build(1L,10L,"owner",4L); + verify(snapshots).insert(argThat((java.util.Collection rows) -> rows.size()==1 && rows.iterator().next().getId()!=null && Long.valueOf(10L).equals(rows.iterator().next().getSnapshotRunId())),eq(500)); + verify(profiles).insert(argThat((java.util.Collection rows) -> rows.size()==1 && rows.iterator().next().getId()!=null),eq(500)); + verify(snapshots,never()).insert(any(SelfopInventorySnapshot.class));verify(profiles,never()).insert(any(SelfopSkuProfile.class));assertEquals("READY",run.getStatus()); + } + @Test void publishedReferenceMustAlsoBeBusinessPurpose() { + run.setStatus("VALIDATED");run.setBasePublishedRunId(9L);scope.setPublishedRunId(9L); + var ref=new SelfopSyncRun();ref.setId(9L);ref.setExecutionPurpose("VALIDATION");ref.setStatus("PUBLISHED");ref.setRunRole("WAREHOUSE_SYNC");ref.setSourceAccount("demo");ref.setWarehouseCode("A");when(runs.selectById(9L)).thenReturn(ref); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.build(1L,10L,"owner",4L)).getMessage()); + verify(snapshots,never()).insert(anyCollection(),anyInt()); + } + @Test void liveUnitConfigCannotOverrideFixedRunConfiguration() { + properties.setQuantityUnit(null); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); + prepare(); properties.setQuantityUnit("PCS"); run.setScopeSnapshotJson("{\"quantityUnit\":null}"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + } + + private void scheduledObservation() { + task.setTaskRole("WAREHOUSE_SYNC"); task.setTaskType("SCHEDULED"); + task.setPlannedAtUtc(run.getFetchStartedAtUtc()); + } + + @Test void firstScheduledSuccessSelectsBeijingDayInPublicationTransaction() { + scheduledObservation(); + // Beijing midnight is the previous UTC date; host timezone is irrelevant. + task.setPlannedAtUtc(LocalDateTime.of(2026, 10, 11, 16, 0)); + run.setFetchStartedAtUtc(task.getPlannedAtUtc().plusMinutes(1)); + run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc().plusMinutes(1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate()); + assertEquals("DAILY_SCHEDULED_FIRST_V1", run.getInventorySampleRuleVersion()); + var order = inOrder(coordination, runs, scopes, tasks); + order.verify(coordination).lockScope(1L); + order.verify(runs).dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12)); + order.verify(scopes).updateById(scope); + order.verify(runs).updateById(run); + order.verify(tasks).updateById(task); + } + + @Test void laterSameDaySuccessPublishesLatestWithoutReplacingSelectedRepresentative() { + scheduledObservation(); + var selected = new SelfopSyncRun(); selected.setId(9L); + selected.setStatus("PUBLISHED"); selected.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + selected.setInventoryDailySampleDate(LocalDate.of(2026, 10, 12)); + when(runs.dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12))).thenReturn(selected); + service.publish(1L, 10L, "owner", 4L); + assertEquals(10L, scope.getPublishedRunId()); + assertNull(run.getInventoryDailySampleDate()); assertNull(run.getInventorySampleRuleVersion()); + assertEquals(LocalDate.of(2026, 10, 12), selected.getInventoryDailySampleDate()); + } + + @Test void failedAttemptDoesNotOccupyDayAndSuccessfulRetryMaySelectIt() { + scheduledObservation(); run.setStatus("FAILED"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + assertNull(run.getInventoryDailySampleDate()); + verify(runs, never()).dailyInventorySample(anyString(), anyString(), any()); + // A new attempt uses a new run; the failed attempt remains immutable. + var failed = run; + run = new SelfopSyncRun(); org.springframework.beans.BeanUtils.copyProperties(failed, run); + run.setId(11L); run.setStatus("READY"); run.setAttemptNo(2); + when(coordination.lockRun(11L)).thenReturn(run); + service.publish(1L, 11L, "owner", 4L); + assertEquals("FAILED", failed.getStatus()); assertNull(failed.getInventoryDailySampleDate()); + assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate()); + } + + @Test void crossMidnightAndOldSlotCatchupRemainCurrentInventoryButNeverDailySamples() { + scheduledObservation(); + run.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 15, 59)); + run.setFetchFinishedAtUtc(LocalDateTime.of(2026, 10, 12, 16, 1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); assertNull(run.getInventoryDailySampleDate()); + prepare(); scheduledObservation(); + task.setPlannedAtUtc(task.getPlannedAtUtc().minusDays(1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); assertNull(run.getInventoryDailySampleDate()); + verify(runs, never()).dailyInventorySample(anyString(), anyString(), any()); + } + + @Test void manualAndIncompleteEvidenceCannotAcquireDailyMarker() { + scheduledObservation(); task.setTaskType("MANUAL"); + service.publish(1L, 10L, "owner", 4L); + assertNull(run.getInventoryDailySampleDate()); + prepare(); scheduledObservation(); run.setFetchFinishedAtUtc(null); + assertNull(InventorySnapshotService.eligibleSampleDate(task, run)); + run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc()); run.setUniqueObjectCount(1L); + assertNull(InventorySnapshotService.eligibleSampleDate(task, run)); + run.setUniqueObjectCount(0L); run.setExecutionPurpose("VALIDATION"); + assertNull(InventorySnapshotService.eligibleSampleDate(task, run)); + } + + @Test void publishedRecoveryRetainsMarkerAndNeverRepublishesEvenWhenSuperseded() { + scheduledObservation(); service.publish(1L, 10L, "owner", 4L); + when(runs.dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12))).thenReturn(run); + scope.setPublishedRunId(11L); // Another successful run has become current. + service.publish(1L, 10L, "owner", 4L); + assertEquals(11L, scope.getPublishedRunId()); + assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate()); + verify(scopes, times(1)).updateById(scope); verify(runs, times(1)).updateById(run); + } + + @Test void unmarkedPublishedRecoveryCannotRetroactivelyElectOldRun() { + scheduledObservation(); + var previous = new SelfopSyncRun(); previous.setId(9L); + previous.setStatus("PUBLISHED"); previous.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + when(runs.dailyInventorySample(anyString(), anyString(), any())).thenReturn(previous); + service.publish(1L, 10L, "owner", 4L); + clearInvocations(runs); + when(runs.dailyInventorySample(anyString(), anyString(), any())).thenReturn(null); + service.publish(1L, 10L, "owner", 4L); + assertNull(run.getInventoryDailySampleDate()); + verify(runs, never()).dailyInventorySample(anyString(), anyString(), any()); + verify(runs, never()).updateById(any(SelfopSyncRun.class)); + } + + @Test void sameQuantityNextDayStillRecordsNewObservationAndRejectsPremarkedCandidate() { + scheduledObservation(); + task.setPlannedAtUtc(task.getPlannedAtUtc().plusDays(1)); + run.setFetchStartedAtUtc(run.getFetchStartedAtUtc().plusDays(1)); + run.setFetchFinishedAtUtc(run.getFetchFinishedAtUtc().plusDays(1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals(LocalDate.of(2026, 10, 13), run.getInventoryDailySampleDate()); + prepare(); scheduledObservation(); run.setInventoryDailySampleDate(LocalDate.of(2026, 10, 12)); + assertEquals("SELFOP_INVENTORY_UNPUBLISHED_SAMPLE", assertThrows(IllegalStateException.class, + () -> service.publish(1L, 10L, "owner", 4L)).getMessage()); + } + + @Test void recoveryRejectsPartiallyPersistedSampleEvidence() { + service.publish(1L, 10L, "owner", 4L); + run.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + assertEquals("SELFOP_INVENTORY_SAMPLE_INVALID", assertThrows(IllegalStateException.class, + () -> service.publish(1L, 10L, "owner", 4L)).getMessage()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java new file mode 100644 index 000000000..5fc54a263 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import java.sql.*; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Actual mapper SQL against an offline synthetic database, not a mocked aggregate. */ +public class OutboundAggregateMapperTest { + JdbcDataSource data; SqlSession session; OutboundAggregateMapper mapper; + @BeforeEach void setup() throws Exception { + data=new JdbcDataSource(); data.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + sql("CREATE TABLE ll_selfop_outbound_order (id BIGINT PRIMARY KEY,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sales_eligible INT,outbound_at_utc TIMESTAMP,business_date DATE,source_timezone VARCHAR(64),business_timezone VARCHAR(64),last_observation_sequence BIGINT)"); + sql("CREATE TABLE ll_selfop_outbound_item (id BIGINT PRIMARY KEY,order_id BIGINT,sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),actual_quantity DECIMAL(20,6))"); + sql("CREATE TABLE ll_selfop_sync_stage (id BIGINT PRIMARY KEY,run_id BIGINT,warehouse_code VARCHAR(64),object_type VARCHAR(32),validation_status VARCHAR(24),observation_sequence BIGINT,observed_at_utc TIMESTAMP)"); + sql("CREATE TABLE ll_selfop_sync_run (id BIGINT PRIMARY KEY,source_account VARCHAR(64),task_id BIGINT,execution_purpose VARCHAR(24))"); + sql("CREATE TABLE ll_selfop_sync_task (id BIGINT PRIMARY KEY,execution_purpose VARCHAR(24),validation_root_task_id BIGINT,scope_id BIGINT,scope_hash VARCHAR(64),apply_state VARCHAR(24),query_from_utc TIMESTAMP,query_until_utc TIMESTAMP,routing_manifest_json VARCHAR)"); + sql("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonExtract'"); + sql("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonUnquote'"); + sql("CREATE TABLE ll_selfop_sku_daily_sales (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,sales_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))"); + sql("CREATE TABLE ll_selfop_sku_profile (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,profile_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),first_seen_at_utc TIMESTAMP,earliest_evidence_at_utc TIMESTAMP,evidence_type VARCHAR(24),evidence_run_id BIGINT,last_seen_run_id BIGINT)"); + Configuration config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data)); + config.setMapUnderscoreToCamelCase(true); config.setLocalCacheScope(LocalCacheScope.STATEMENT); config.addMapper(OutboundAggregateMapper.class); + session=new SqlSessionFactoryBuilder().build(config).openSession(true); mapper=session.getMapper(OutboundAggregateMapper.class); + } + @AfterEach void close() { if(session!=null) session.close(); } + void sql(String query) throws Exception {try(Connection c=data.getConnection();Statement s=c.createStatement()){s.execute(query);}} + void order(long id,String account,String customer,String warehouse,String sku,int eligible,String time,int quantity) throws Exception { + sql("INSERT INTO ll_selfop_sync_task (id,execution_purpose,validation_root_task_id) VALUES ("+id+",'BUSINESS',NULL)"); + sql("INSERT INTO ll_selfop_sync_run VALUES ("+id+",'"+account+"',"+id+",'BUSINESS')"); + sql("INSERT INTO ll_selfop_sync_stage VALUES ("+id+","+id+",'"+warehouse+"','OUTBOUND_ORDER','VALID',"+id+",'2026-10-12 01:00:00')"); + sql("INSERT INTO ll_selfop_outbound_order VALUES ("+id+",'"+account+"','"+customer+"','"+warehouse+"',"+eligible+",'"+time+"','2026-10-01','Asia/Shanghai','Asia/Shanghai',"+id+")"); + sql("INSERT INTO ll_selfop_outbound_item VALUES ("+id+","+id+",'"+sku+"','item','PCS',"+quantity+")"); + } + @Test void aggregatesDistinctOwnersAndRetractsCancelledMigratedAndCorrectedEvidence() throws Exception { + order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2); + order(2,"demo","C1","A","SKU",1,"2026-10-02 00:00:00",3); + order(3,"demo","C2","A","SKU",1,"2026-10-01 00:00:00",7); + order(4,"demo","C1","A","OLD",0,"2026-09-01 00:00:00",99); + order(5,"demo","C1","B","MOVED",1,"2026-09-01 00:00:00",99); + assertEquals(0,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS")); + var daily=mapper.dailyBatch("demo","A",null,null,null); assertEquals(2,daily.size()); + assertEquals(0,new java.math.BigDecimal("5").compareTo(daily.get(0).getOutboundQuantity())); assertEquals(2L,daily.get(0).getOutboundOrderCount()); + var profiles=mapper.profileBatch("demo","A",null,null); assertEquals(2,profiles.size()); + assertEquals(LocalDateTime.of(2026,10,1,0,0),profiles.get(0).getEarliestEvidenceAtUtc()); assertEquals(1L,profiles.get(0).getEvidenceRunId()); + sql("UPDATE ll_selfop_outbound_order SET sales_eligible=0 WHERE id=1"); + var corrected=mapper.profileBatch("demo","A",null,null); assertEquals(LocalDateTime.of(2026,10,2,0,0),corrected.get(0).getEarliestEvidenceAtUtc()); assertEquals(2L,corrected.get(0).getEvidenceRunId()); + assertEquals(1,mapper.profileBatch("demo","A","C1","SKU").size()); + } + @Test void validationEvidenceAndAccountSequenceCollisionAreFailClosed() throws Exception { + order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2); + order(2,"other","C1","A","SKU",1,"2026-10-01 00:00:00",9); + sql("UPDATE ll_selfop_sync_stage SET observation_sequence=1 WHERE id=2"); + assertEquals(0,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS")); + sql("UPDATE ll_selfop_sync_run SET execution_purpose='VALIDATION' WHERE id=1"); + assertEquals(1,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS")); + assertTrue(mapper.profileBatch("demo","A",null,null).isEmpty()); + } + @Test void databaseResultPagesAndMultiRowInsertsAreBounded() throws Exception { + // Insert synthetic local orders without any provider I/O. + try(Connection c=data.getConnection();PreparedStatement statement=c.prepareStatement("INSERT INTO ll_selfop_outbound_order VALUES (?,'demo','C1','A',1,'2026-10-01 00:00:00','2026-10-01','Asia/Shanghai','Asia/Shanghai',?)"); + PreparedStatement item=c.prepareStatement("INSERT INTO ll_selfop_outbound_item VALUES (?, ?, ?, 'item','PCS',1)")) { + for(long id=1;id<=501;id++) {statement.setLong(1,id); statement.setLong(2,id);statement.addBatch();item.setLong(1,id); item.setLong(2,id);item.setString(3,String.format("SKU%04d",id));item.addBatch();} + statement.executeBatch(); item.executeBatch(); + } + var first=mapper.dailyBatch("demo","A",null,null,null); assertEquals(500,first.size()); + var last=first.get(499); var second=mapper.dailyBatch("demo","A",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate());assertEquals(1,second.size()); + long id=1; for(var row:first) {row.setId(id++);row.setSalesRunId(900L);row.setSourceAccount("demo");row.setWarehouseCode("A");row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(row.getCreatedAt());} + assertEquals(500,mapper.insertDaily(first)); + try(Connection c=data.getConnection();ResultSet result=c.createStatement().executeQuery("SELECT COUNT(*) FROM ll_selfop_sku_daily_sales")){assertTrue(result.next());assertEquals(500,result.getLong(1));} + } + public static String jsonExtract(String value,String path) throws Exception { + if(value==null)return null; + return new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).path("directoryComplete").asText(); + } + public static String jsonUnquote(String value) {return value;} + @Test void databaseCoverageJoinsOverlapsAndStopsAtFirstUnprovedGap() throws Exception { + sql("INSERT INTO ll_selfop_sync_task (id,execution_purpose,scope_id,scope_hash,apply_state,query_from_utc,query_until_utc,routing_manifest_json) VALUES (1,'BUSINESS',1,'h','APPLIED','2026-10-01 00:00:00','2026-10-03 00:00:00','{\"directoryComplete\":true}'),(2,'BUSINESS',1,'h','APPLIED','2026-10-02 00:00:00','2026-10-04 00:00:00','{\"directoryComplete\":true}'),(3,'BUSINESS',1,'h','APPLIED','2026-10-05 00:00:00','2026-10-06 00:00:00','{\"directoryComplete\":true}'),(4,'VALIDATION',1,'h','APPLIED','2026-10-04 00:00:00','2026-10-05 00:00:00','{\"directoryComplete\":true}')"); + var coverage=mapper.contiguousCoverage(1L,"h",LocalDateTime.of(2026,10,1,0,0),null,null); + assertEquals(LocalDateTime.of(2026,10,1,0,0),coverage.getCoveredFromUtc()); + assertEquals(LocalDateTime.of(2026,10,4,0,0),coverage.getCoveredUntilUtc()); + sql("UPDATE ll_selfop_sync_task SET execution_purpose='BUSINESS' WHERE id=4"); + assertEquals(LocalDateTime.of(2026,10,6,0,0),mapper.contiguousCoverage(1L,"h",LocalDateTime.of(2026,10,1,0,0),null,null).getCoveredUntilUtc()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java new file mode 100644 index 000000000..4a8a2c886 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class OutboundCandidateBuilderTest { + final OutboundLockMapper locks=mock(OutboundLockMapper.class); + final OutboundFactService facts=mock(OutboundFactService.class); + final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + final SelfopSkuDailySalesMapper daily=mock(SelfopSkuDailySalesMapper.class); + final SelfopSkuProfileMapper profiles=mock(SelfopSkuProfileMapper.class); + final OutboundAggregateMapper aggregates=mock(OutboundAggregateMapper.class); + final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class); + final OutboundCandidateTransaction transaction=new OutboundCandidateTransaction(locks,facts,runs,tasks,scopes,daily,profiles,aggregates,new SelfopAnalyticsProperties()); + final OutboundCandidateBuilder builder=new OutboundCandidateBuilder(transaction); + SelfopSyncScope scope; SelfopSyncRun run; SelfopSyncTask task; + @BeforeEach void setup() { + scope=new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); scope.setDataFamily("OUTBOUND"); + scope.setScopeHash("scope"); scope.setLeaseOwner("worker"); scope.setFenceToken(2L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); + scope.setFactRevision(5L); scope.setSourceTimezone("Asia/Shanghai"); scope.setBusinessTimezone("Asia/Shanghai"); scope.setScopeJson("{\"quantityUnit\":\"PCS\"}"); + scope.setInitialFromUtc(LocalDateTime.of(2026,9,12,0,0)); + run=new SelfopSyncRun(); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash("scope"); + run.setExecutionPurpose("BUSINESS"); run.setScopeSnapshotJson(scope.getScopeJson()); + run.setAttemptNo(1); + run.setFenceToken(2L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("FACTS_APPLIED"); + task=new SelfopSyncTask(); task.setId(20L); task.setExecutionPurpose("BUSINESS"); task.setApplyState("APPLIED"); + task.setScopeId(1L);task.setSourceAccount("demo");task.setWarehouseCode("A");task.setScopeHash("scope"); + task.setQueryFromUtc(scope.getInitialFromUtc()); task.setQueryUntilUtc(LocalDateTime.of(2026,10,12,0,0)); + SelfopRoutingManifest manifest=new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); task.setRoutingManifestJson(SelfopSupport.json(manifest)); + when(locks.scope(1L)).thenReturn(scope); when(locks.run(10L)).thenReturn(run); when(locks.task(20L)).thenReturn(task); + SelfopSyncRun coverage=new SelfopSyncRun();coverage.setCoveredFromUtc(task.getQueryFromUtc());coverage.setCoveredUntilUtc(task.getQueryUntilUtc()); + when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(coverage); + } + @Test void fullRebuildWithNoCurrentFactsRetractsAllRemovedEvidence() { + builder.build(1L,10L,"worker",2); + verify(profiles).delete(any()); verify(aggregates,never()).insertProfiles(any()); + verify(aggregates,never()).insertDaily(any()); verify(facts,atLeastOnce()).assertNoBarrier(scope); + assertEquals(5L,run.getCalculatedFactRevision()); assertEquals("READY",run.getStatus()); + assertNotNull(run.getBuildStartedAtUtc()); assertEquals(LocalDate.of(2026,9,13),run.getCompleteDayFrom()); + assertEquals(LocalDate.of(2026,10,12),run.getCompleteDayUntil()); assertNull(scope.getPublishedRunId()); + } + @Test void pagesAreKeysetBoundedAndWrittenInBulk() { + List rows=new ArrayList<>(); + for(int n=0;n<500;n++) { SelfopSkuDailySales row=new SelfopSkuDailySales(); row.setCustomerCode("c"); row.setSkuCode(String.format("%03d",n)); row.setBusinessDate(LocalDate.of(2026,10,1)); rows.add(row); } + SelfopSkuDailySales tail=new SelfopSkuDailySales(); tail.setCustomerCode("c"); tail.setSkuCode("500"); tail.setBusinessDate(LocalDate.of(2026,10,1)); + when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(rows); + when(aggregates.dailyBatch("demo","A","c","499",tail.getBusinessDate())).thenReturn(List.of(tail)); + when(aggregates.dailyBatch("demo","A","c","500",tail.getBusinessDate())).thenReturn(List.of()); + when(daily.selectCount(any())).thenReturn(501L); + builder.build(1L,10L,"worker",2); + verify(aggregates).insertDaily(rows); verify(aggregates).insertDaily(List.of(tail)); + verify(daily,never()).insert(any(SelfopSkuDailySales.class)); + assertTrue(rows.stream().allMatch(r->r.getSalesRunId().equals(10L) && r.getId()!=null)); + } + @Test void unchangedFactsButAdvancedCoverageCannotReuseZeroSales() { + scope.setPublishedRunId(9L); scope.setPublishedFactRevision(5L); + SelfopSyncRun published=new SelfopSyncRun(); published.setId(9L); published.setScopeId(1L); published.setScopeHash("scope"); + published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L); + published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1)); + published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc()); + published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published); + assertNull(builder.reusable(1L,10L,"worker",2)); + published.setCoveredUntilUtc(task.getQueryUntilUtc()); assertEquals(9L,builder.reusable(1L,10L,"worker",2)); + verifyNoInteractions(daily,profiles); + } + @Test void validationBuildAndInvalidEvidenceFailBeforeDeletingCandidate() { + run.setExecutionPurpose("VALIDATION"); assertThrows(IllegalStateException.class,()->builder.build(1L,10L,"worker",2)); + run.setExecutionPurpose("BUSINESS"); when(aggregates.invalidFacts(anyString(),anyString(),anyString(),anyString(),anyString())).thenReturn(1L); + assertThrows(IllegalStateException.class,()->builder.build(1L,10L,"worker",2)); verifyNoInteractions(daily,profiles); + } + @Test void partialBatchCommitsCheckpointAndRevisionChangePreventsReady() { + transaction.begin(1L,10L,"worker",2); + SelfopSkuDailySales row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("s");row.setBusinessDate(LocalDate.of(2026,10,1)); + when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(List.of(row)); + var checkpoint=transaction.batch(1L,10L,"worker",2); + assertEquals(1,checkpoint.dailyRows());assertEquals("BUILDING",run.getStatus());assertNotNull(task.getProgressUpdatedAtUtc());assertNotNull(run.getHeartbeatAtUtc()); + assertTrue(task.getProgressJson().contains("candidateBuild"));assertNull(scope.getPublishedRunId()); + Map progress=SelfopSupport.read(task.getProgressJson(),Map.class); + assertEquals("BUILD",progress.get("phase"));assertEquals("BATCH",progress.get("unit"));assertEquals(1L,((Number)progress.get("completed")).longValue()); + assertEquals("ATTEMPT_ONLY",progress.get("countBasis"));assertFalse(progress.containsKey("processedCount")); + assertDoesNotThrow(()->Instant.parse((String)progress.get("stageStartedAt")));assertTrue(progress.get("stages") instanceof List); + scope.setFactRevision(6L);assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2)); + assertEquals("BUILDING",run.getStatus());verify(aggregates,times(1)).insertDaily(any()); + } + @Test void crashResumesSameRunCursorWithoutDeletingCommittedRows() { + var started=transaction.begin(1L,10L,"worker",2);clearInvocations(daily,profiles); + assertEquals(started,transaction.begin(1L,10L,"worker",2));verifyNoInteractions(daily,profiles); + scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1)); + assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2)); + } + @Test void observationChangeWithoutRevisionStillAbortsCandidate() { + transaction.begin(1L,10L,"worker",2); + when(aggregates.observationsChangedSince(eq("demo"),eq("A"),any())).thenReturn(1L); + assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2)); + verify(aggregates,never()).insertDaily(any());assertEquals("BUILDING",run.getStatus()); + } + @Test void facadeHasNoLongTransactionAndWorkerMethodsDeclareOwnTransactions() throws Exception { + assertNull(OutboundCandidateBuilder.class.getMethod("build",Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class)); + for(String name:List.of("begin","batch","finish"))assertNotNull(OutboundCandidateTransaction.class.getMethod(name,Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class)); + } + @Test void onlyRoutineFirstCollectionIsCoalesced() { + task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(1); LocalDateTime now=SelfopSupport.utcNow(); + assertFalse(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(29),now)); + assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(30),now)); + task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); + task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java new file mode 100644 index 000000000..9e25c2441 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java @@ -0,0 +1,130 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class OutboundFactServiceTest { + final OutboundLockMapper locks=mock(OutboundLockMapper.class); + final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class); + final SelfopSyncStageMapper stages=mock(SelfopSyncStageMapper.class); + final SelfopOutboundOrderMapper orders=mock(SelfopOutboundOrderMapper.class); + final SelfopOutboundItemMapper items=mock(SelfopOutboundItemMapper.class); + final OutboundFactService service=new OutboundFactService(locks,tasks,runs,scopes,stages,orders,items); + SelfopSyncTask task; SelfopSyncRun run; SelfopSyncStage stage; SelfopSyncScope a,b; SelfopOutboundOrder old; + Map leases; + SelfopPayloads.Order payload; + @BeforeEach void setup() { + a=scope(1L,"A"); b=scope(2L,"B"); leases=Map.of("A",new OutboundFactService.Lease("worker",7),"B",new OutboundFactService.Lease("worker",7)); + task=new SelfopSyncTask(); task.setId(10L); task.setSourceAccount("demo"); task.setScopeId(2L); + task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("WAREHOUSE_SYNC"); + task.setWarehouseCode("B"); task.setScopeHash("scope"); task.setUpstreamTaskId(9L); task.setApplyState("NOT_STARTED"); + run=new SelfopSyncRun(); run.setId(20L); run.setTaskId(10L); run.setScopeId(2L); run.setScopeHash("scope"); run.setFenceToken(7L); + run.setExecutionPurpose("BUSINESS"); + run.setAttemptNo(1); + payload=new SelfopPayloads.Order("demo","owner","B","delivery",null,100,null,"2026-10-11 12:00:00",List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("3"),"piece"))); + stage=new SelfopSyncStage(); stage.setId(30L); stage.setStageBatchId(9L); stage.setObjectType("OUTBOUND_ORDER"); stage.setValidationStatus("VALID"); + stage.setRunId(8L); + SelfopSyncRun sourceRun=new SelfopSyncRun(); sourceRun.setId(8L); sourceRun.setTaskId(9L); sourceRun.setExecutionPurpose("BUSINESS"); + SelfopSyncTask sourceTask=new SelfopSyncTask(); sourceTask.setId(9L); sourceTask.setExecutionPurpose("BUSINESS"); + sourceRun.setSourceAccount("demo"); sourceTask.setSourceAccount("demo"); + when(runs.selectById(8L)).thenReturn(sourceRun); when(tasks.selectById(9L)).thenReturn(sourceTask); + stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","owner","delivery"))); stage.setWarehouseCode("B"); stage.setObservationSequence(2L); + stage.setPayloadJson(SelfopSupport.json(payload)); stage.setObservedAtUtc(SelfopSupport.utcNow()); + stage.setContentHash(SelfopSupport.hash(payload)); + SelfopRoutingManifest manifest=new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); + SelfopRoutingManifest.Entry entry=new SelfopRoutingManifest.Entry(); entry.setEffectiveStageId(30L); manifest.getObjects().put(stage.getObjectKeyHash(),entry); + task.setRoutingManifestJson(SelfopSupport.json(manifest)); + old=new SelfopOutboundOrder(); old.setId(40L); old.setWarehouseCode("A"); old.setSourceAccount("demo"); old.setCustomerCode("owner"); + old.setDeliveryNo("delivery"); old.setRevisionNo(1L); old.setLastObservationSequence(1L); old.setContentHash("old"); + old.setBusinessDate(LocalDate.of(2026,10,10)); old.setOutboundAtUtc(LocalDateTime.of(2026,10,10,12,0)); old.setSalesEligible(1); + when(tasks.selectById(10L)).thenReturn(task); when(locks.task(10L)).thenReturn(task); when(locks.run(20L)).thenReturn(run); + when(stages.selectById(30L)).thenReturn(stage); when(locks.stage(30L)).thenReturn(stage); + when(orders.selectOne(any())).thenReturn(old); when(locks.order("demo","owner","delivery")).thenReturn(old); + when(locks.warehouse("demo","A")).thenReturn(a); when(locks.warehouse("demo","B")).thenReturn(b); + when(locks.newestObservation("demo",stage.getObjectKeyHash())).thenReturn(2L); + when(items.selectList(any())).thenReturn(List.of()); when(tasks.selectCount(any())).thenReturn(0L); + } + private SelfopSyncScope scope(Long id,String warehouse) { + SelfopSyncScope s=new SelfopSyncScope(); s.setId(id); s.setWarehouseCode(warehouse); s.setSourceAccount("demo"); s.setDataFamily("OUTBOUND"); + s.setScopeHash("scope"); s.setSourceTimezone("UTC"); s.setBusinessTimezone("UTC"); s.setScopeJson("{\"quantityUnit\":\"piece\"}"); + s.setLeaseOwner("worker"); s.setFenceToken(7L); s.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); s.setFactRevision(0L); return s; + } + @Test void crossWarehouseRevisionInvalidatesBothAndLeavesBarrierUntilFinish() { + service.merge(10L,20L,List.of(30L),leases); + assertEquals("B",old.getWarehouseCode()); assertEquals(1L,a.getFactRevision()); assertEquals(1L,b.getFactRevision()); + assertEquals(1,a.getRebuildRequired()); assertEquals(1,b.getRebuildRequired()); assertEquals("APPLYING",task.getApplyState()); + verify(tasks,times(2)).insert(any(SelfopSyncTask.class)); + assertTrue(task.getAffectedWarehouseCodesJson().contains("A")); assertTrue(task.getAffectedWarehouseCodesJson().contains("B")); + verify(stages).update(isNull(),any()); + } + @Test void expiredOldWorkerCannotWriteFactOrBarrier() { + a.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1)); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); verify(tasks,never()).updateById(any(SelfopSyncTask.class)); + } + @Test void newerPersistedDetailForcesRecheckInsteadOfCompletingOldSlice() { + when(locks.newestObservation("demo",stage.getObjectKeyHash())).thenReturn(3L); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); + } + @Test void cancellationRemovesOldDateAndEligibility() { + SelfopPayloads.Order cancelled=new SelfopPayloads.Order("demo","owner","B","delivery",null,99,null,null,payload.items()); + stage.setPayloadJson(SelfopSupport.json(cancelled)); stage.setContentHash(SelfopSupport.hash(cancelled)); + service.merge(10L,20L,List.of(30L),leases); + assertNull(old.getOutboundAtUtc()); assertNull(old.getBusinessDate()); assertEquals(0,old.getSalesEligible()); + verify(items).delete(any()); + } + @Test void deadTaskRetainsCrossTaskPublicationBarrier() { + task.setApplyState("BLOCKED"); task.setStatus("DEAD"); + when(locks.barriers("demo","B")).thenReturn(List.of(task)); + assertThrows(IllegalStateException.class,()->service.assertNoBarrier(b)); + } + @Test void incompleteRoutingSetCannotClearBarrier() { + assertThrows(IllegalStateException.class,()->service.finish(10L,20L,List.of(),leases)); + verify(tasks,never()).updateById(any(SelfopSyncTask.class)); + } + @Test void validationCannotMergeOrCompleteFormalTask() { + task.setExecutionPurpose("VALIDATION"); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + assertThrows(IllegalStateException.class,()->service.finish(10L,20L,List.of(30L),leases)); + verifyNoInteractions(scopes); verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); + } + @Test void businessCannotReuseValidationDetailStage() { + SelfopSyncRun validation=new SelfopSyncRun(); validation.setExecutionPurpose("VALIDATION"); + when(runs.selectById(8L)).thenReturn(validation); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); verify(items,never()).delete(any()); + } + @Test void mergeCheckpointCountsPersistedEffectiveReferencesOnceAcrossReplay() { + service.merge(10L,20L,List.of(30L),leases); + SelfopRoutingManifest saved=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + assertEquals(30L,saved.getObjects().get(stage.getObjectKeyHash()).getAppliedStageId()); + Map progress=SelfopSupport.read(task.getProgressJson(),Map.class); + assertEquals("MERGE",progress.get("phase"));assertEquals("ORDER",progress.get("unit")); + assertEquals(1L,((Number)progress.get("completed")).longValue());assertEquals(1L,((Number)progress.get("total")).longValue()); + service.merge(10L,20L,List.of(30L),leases); + assertEquals(1L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue()); + assertEquals(1L,a.getFactRevision());assertEquals(1L,b.getFactRevision()); + } + @Test void priorAppliedReferencesCarryForwardButOutdatedReferencesAreNotCompleted() { + SelfopRoutingManifest manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + SelfopRoutingManifest.Entry prior=new SelfopRoutingManifest.Entry();prior.setEffectiveStageId(31L);prior.setAppliedStageId(31L); + manifest.getObjects().put("other-key",prior);task.setRoutingManifestJson(SelfopSupport.json(manifest)); + service.merge(10L,20L,List.of(30L),leases); + assertEquals(2L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue()); + manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);manifest.getObjects().get("other-key").setEffectiveStageId(32L);task.setRoutingManifestJson(SelfopSupport.json(manifest)); + service.merge(10L,20L,List.of(30L),leases); + assertEquals(1L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java new file mode 100644 index 000000000..a7a0f039f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class OutboundPolicyTest { + private SelfopPayloads.Order order(String time,Integer status,List items) { + return new SelfopPayloads.Order("demo","owner","warehouse","delivery",null,status,"2020-01-01 00:00:00",time,items); + } + @Test void actualSkuLinesAreCombinedWithoutForecastOrPackageQuantities() { + SelfopPayloads.Order p=order("2026-10-11 09:00:00",100,List.of( + new SelfopPayloads.Item("sku","name",new BigDecimal("2"),"piece"), + new SelfopPayloads.Item("sku","name",new BigDecimal("3"),"piece"))); + assertEquals(new BigDecimal("5"),OutboundPolicy.normalize(p).items().get(0).actualQuantity()); + assertEquals(1,OutboundPolicy.normalize(p).items().size()); + } + @Test void missingQuantityAndMixedUnitCannotBecomeZero() { + assertThrows(IllegalStateException.class,()->OutboundPolicy.normalize(order(null,100,List.of(new SelfopPayloads.Item("sku",null,null,"piece"))))); + assertThrows(IllegalStateException.class,()->OutboundPolicy.normalize(order(null,100,List.of( + new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"piece"),new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"box"))))); + } + @Test void unknownSourceTimezoneNeverUsesWarehouseOrSystemDefault() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setBusinessTimezone("Asia/Shanghai"); + assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-10-11 09:00:00",100,List.of()),scope)); + } + @Test void dstOverlapAndGapAreExplicitlyRejected() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("America/New_York"); + assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-11-01 01:30:00",100,List.of()),scope)); + assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-03-08 02:30:00",100,List.of()),scope)); + } + @Test void cancellationWithQuantityAndNoOutboundDateHasNoSalesTime() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + assertNull(OutboundPolicy.outboundUtc(order(null,99,List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"piece"))),scope)); + } + @Test void rollingMonthDoesNotInventFirstPartialDayCoverage() { + var days=OutboundPolicy.completeDays(LocalDateTime.of(2026,9,12,0,0),LocalDateTime.of(2026,10,12,0,0),"Asia/Shanghai"); + assertEquals(LocalDate.of(2026,9,13),days.from()); assertEquals(LocalDate.of(2026,10,12),days.until()); + assertTrue(LocalDate.of(2026,10,11).isBefore(days.until())); + } + @Test void disconnectedBackfillDoesNotJumpOverCoverageGap() { + LocalDateTime start=LocalDateTime.of(2026,9,12,0,0); + var result=OutboundCandidateBuilder.contiguous(start,List.of( + new OutboundCandidateBuilder.Coverage(start,start.plusDays(1)), + new OutboundCandidateBuilder.Coverage(start.plusDays(2),start.plusDays(3)))); + assertEquals(start.plusDays(1),result.until()); + } + @Test void newDiscoverySliceUsesHalfOpenUtcBoundaries() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1); + assertFalse(OutboundPolicy.belongsToSlice(order("2026-10-11 11:59:59",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-11 12:00:00",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-11 12:59:59",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertFalse(OutboundPolicy.belongsToSlice(order("2026-10-11 13:00:00",100,List.of()),scope,from,until,false,"SCHEDULED")); + } + @Test void endpointReturnedByAdjacentSourceSlicesBelongsOnlyToTheSecondInternalSlice() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), middle=from.plusHours(1), until=middle.plusHours(1); + var p=order("2026-10-11 13:00:00",100,List.of()); + assertFalse(OutboundPolicy.belongsToSlice(p,scope,from,middle,false,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(p,scope,middle,until,false,"SCHEDULED")); + } + @Test void correctedKnownDateAndCancellationRemainApplicableOutsideTheSlice() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1); + var corrected=order("2026-10-01 00:00:00",100,List.of()); + assertTrue(OutboundPolicy.belongsToSlice(corrected,scope,from,until,true,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(corrected,scope,from,until,false,"RECHECK")); + assertTrue(OutboundPolicy.belongsToSlice(order(null,99,List.of()),scope,from,until,false,"SCHEDULED")); + } + @Test void unconfirmedTimezoneRetainsEvidenceWithoutAssumingUtc() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setBusinessTimezone("Asia/Shanghai"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1); + assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-01 00:00:00",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertNull(scope.getSourceTimezone()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java new file mode 100644 index 000000000..0310630cc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java @@ -0,0 +1,270 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */ +class AnalyticsQueryServiceTest { + private static final String SH="a".repeat(64), IH="b".repeat(64); + private static final LocalDate D=LocalDate.of(2026,10,12); + private static final Instant NOW=Instant.parse("2026-10-12T04:00:00Z"); + private AnalyticsReadMapper mapper; + private AnalyticsQueryService service; + @BeforeEach void setUp(){ + mapper=mock(AnalyticsReadMapper.class); service=at(NOW); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); + when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); + when(mapper.count(any())).thenReturn(1L); + when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40))))); + when(mapper.inventoryCount(any(),any())).thenReturn(1L); + doAnswer(inv->List.of(inventory(true,"300",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),time(D.minusDays(40)),inv.getArgument(0,AnalyticsSqlRequest.class).complete30()?"150":null))).when(mapper).inventoryPage(any(),any()); + when(mapper.excluded(any())).thenReturn(List.of()); + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40)))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(35)))); + when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of()); + } + @Test void summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){ + when(mapper.count(any())).thenReturn(240L); + var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null)); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); + verify(mapper).count(cap.capture()); verify(mapper).page(cap.capture()); + assertEquals(cap.getAllValues().get(0),cap.getAllValues().get(1)); + var p=cap.getValue(); assertEquals("SUMMARY",p.kind()); assertEquals(11,p.salesRun()); assertEquals(22,p.inventoryRun()); + assertEquals(D.minusDays(2),p.from()); assertEquals(D.plusDays(1),p.until()); + assertEquals("demo",p.account()); assertEquals("W",p.warehouse()); assertNull(p.customer()); + assertEquals(20,p.size()); assertEquals(0,p.offset()); assertEquals(240,response.total()); assertEquals(12,response.pages()); + verify(mapper,never()).profile(anyLong(),anyString(),anyString(),anyString(),anyString()); + verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void twoZeroAndTodayThreeKeepFormalNullObservedFiveAndAscendingWholeAxis(){ + when(mapper.daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1))).thenReturn(List.of( + new Daily("C","SKU",D,n("3"),1L,"PCS"),new Daily("C","SKU",D.minusDays(2),n("2"),1L,"PCS"))); + var response=service.dailySales(chart("LAST_3_DAYS",null,null,"C","SKU")); + assertEquals(List.of(D.minusDays(2),D.minusDays(1),D),response.points().stream().map(AnalyticsResponses.Point::businessDate).toList()); + assertEquals(List.of("COMPLETE","COMPLETE","PARTIAL"),response.points().stream().map(AnalyticsResponses.Point::dataStatus).toList()); + assertEquals(n("2"),response.points().get(0).outboundQuantity()); assertEquals(BigDecimal.ZERO,response.points().get(1).outboundQuantity()); + assertNull(response.points().get(2).outboundQuantity()); assertEquals(n("3"),response.points().get(2).observedQuantity()); + assertNull(response.summary().outboundQuantity()); assertEquals(n("5"),response.summary().observedQuantity()); + assertEquals("PARTIAL",response.summary().dataStatus()); assertEquals(2,response.summary().completeDayCount()); + assertEquals(1,response.summary().partialDayCount()); assertEquals("PCS",response.summary().quantityUnit()); + verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1)); + verify(mapper,never()).page(any()); verify(mapper,never()).count(any()); + var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU")).records().get(0); + assertEquals(response.summary().observedQuantity(),summary.observedQuantity()); + assertEquals(response.summary().outboundQuantity(),summary.outboundQuantity()); assertEquals(response.summary().dataStatus(),summary.dataStatus()); + } + @Test void dailyLimitIsInclusive366AndRejects367BeforeReadingBusinessRows(){ + assertEquals(366,service.dailySales(chart("CUSTOM",D.minusDays(365),D,"C","SKU")).points().size()); + verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(365),D.plusDays(1)); clearInvocations(mapper); + assertThrows(BizException.class,()->service.dailySales(chart("CUSTOM",D.minusDays(366),D,"C","SKU"))); + verify(mapper,never()).profile(anyLong(),anyString(),anyString(),anyString(),anyString()); + verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void dailyRequiresExactOwnerSkuAndRejectsMissingCandidate(){ + assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,null,"SKU"))); + assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,"C",null))); + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(null); when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(null); + assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,"C","SKU"))); + verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void historyBeforeInventoryEvidenceStaysUnknownAndNoneCannotReviveIt(){ + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(null); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(1)).plusHours(16))); + var response=service.dailySales(chart("CUSTOM",D.minusDays(3),D.minusDays(2),"C","SKU")); + assertNull(response.summary().outboundQuantity()); assertNull(response.summary().observedQuantity()); + assertTrue(response.points().stream().allMatch(p->"UNKNOWN".equals(p.dataStatus())&&p.reasonCodes().contains("SKU_HISTORY_UNKNOWN"))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","NONE",time(D.minusDays(100)))); + assertNull(service.dailySales(chart("TODAY",null,null,"C","SKU")).summary().observedQuantity()); + } + @Test void foreignUnitsConflictsAndUnknownSourceCannotProduceSales(){ + for(String unit:List.of("BOX")){ + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile(unit,"VALID_OUTBOUND",time(D.minusDays(40)))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("BOX","INVENTORY_OBSERVATION",time(D.minusDays(40)))); + var r=service.dailySales(chart("TODAY",null,null,"C","SKU")); + assertNull(r.summary().observedQuantity()); assertTrue(r.summary().reasonCodes().contains("UNIT_UNKNOWN_OR_CONFLICT")); + } + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40)))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile(null,"INVENTORY_OBSERVATION",time(D.minusDays(40)))); + when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of(new Daily("C","SKU",D.minusDays(1),n("4"),1L,"PCS"))); + var independent=service.dailySales(chart("CUSTOM",D.minusDays(1),D.minusDays(1),"C","SKU")); + assertEquals(0,n("4").compareTo(independent.summary().outboundQuantity())); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(40)))); + when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of()); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS",null)); + var r=service.dailySales(chart("TODAY",null,null,"C","SKU")); + assertEquals("SOURCE_TIMEZONE_UNKNOWN",r.context().salesStatus()); assertNull(r.summary().observedQuantity()); + } + @Test void contextExpiresAtThirtyMinutesAndOldSalesDeadlineClipsIt(){ + assertEquals(NOW.plusSeconds(1800),service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt()); + assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options())); + Instant deadline=NOW.plusSeconds(600); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),deadline.minus(Duration.ofDays(7)),"PUBLISHED","BUSINESS","Asia/Shanghai")); + assertEquals(deadline,service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt()); + assertThrows(BizException.class,()->at(deadline).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options())); + } + @Test void salesRetentionCannotBeConfiguredBelowSevenDays(){ + org.springframework.test.util.ReflectionTestUtils.setField(service,"retentionDays",1); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),NOW.minus(Duration.ofDays(2)),"PUBLISHED","BUSINESS","Asia/Shanghai")); + assertEquals(NOW.plusSeconds(1800),service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt()); + } + @Test void missingOrReversedContinuousCoverageNeverAuthorizesFormalThirtyDayZeros(){ + Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"); + for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){ + when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose())); + var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d()); + } + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any()); + assertTrue(cap.getAllValues().stream().noneMatch(AnalyticsSqlRequest::complete30)); + } + @Test void pinnedDateSurvivesMidnightAndNewPublishedPointers(){ + Instant before=Instant.parse("2026-10-12T15:55:00Z"),after=before.plusSeconds(600); + var first=at(before).salesSummary(chart("TODAY",null,null,null,null)); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,33L),scope(2,"INVENTORY",IH,44L))); + var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2)); + assertEquals(D,second.context().anchorDate()); assertEquals(D,second.context().rangeStartDate()); + assertEquals(D.plusDays(1),second.context().rangeEndDateExclusive()); assertEquals(11L,second.context().salesRunId()); + verify(mapper,never()).publishedRun(33); verify(mapper,never()).publishedRun(44); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture()); assertEquals(20,cap.getValue().offset()); + } + @Test void missingVersionZeroStaysMissingAfterNewPointerAppears(){ + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,0L),scope(2,"INVENTORY",IH,0L))); + var first=service.salesSummary(chart("TODAY",null,null,null,null)); assertEquals(0L,first.context().salesRunId()); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2)); + assertEquals(0L,second.context().inventoryRunId()); assertEquals(0L,second.context().salesRunId()); verify(mapper,never()).publishedRun(anyLong()); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture()); + assertEquals(0,cap.getValue().salesRun()); assertEquals(0,cap.getValue().inventoryRun()); assertFalse(cap.getValue().salesKnown()); + } + @Test void hashScopePurposeAndPublicationMustMatch(){ + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,IH,IH,NOW,1),options())); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(9,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"READY","BUSINESS","Asia/Shanghai")); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","VALIDATION","Asia/Shanghai")); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); verify(mapper,never()).page(any()); + } + @Test void warehouseMismatchAndMissingWarehouseAreRejected(){ + assertThrows(BizException.class,()->service.inventoryCoverage(new AnalyticsQuery("demo",null,null,null,null,null,null,null,null,1,20,null,null,null,null,null),options())); + var foreign=scope(1,"OUTBOUND",SH,11L); + when(mapper.scopes("demo","W")).thenReturn(List.of(new Scope(foreign.id(),"demo","OTHER",foreign.dataFamily(),SH,"Asia/Shanghai","Asia/Shanghai", + 93600,11L,1L,1L,false,foreign.knownOrderRoundFinishedAtUtc(),foreign.historyRoundFinishedAtUtc(),7),scope(2,"INVENTORY",IH,22L))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); verify(mapper,never()).page(any()); + } + @Test void clockBaselineMustNotBeFutureOrBeforePublicationAndPagesNeedContext(){ + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.plusSeconds(1),1),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(7200),1),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,null,2),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(new AnalyticsQuery("demo","W",null,null,null,null,null,null,null,2,20,null,null,null,null,null),options())); + } + @Test void zeroWinsOverUnknownUnitButDynamicStaleZeroDoesNot(){ + doAnswer(inv->List.of(inventory(false,"0",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),null,null))).when(mapper).inventoryPage(any(),any()); + var row=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertEquals(n("0.0"),row.inventoryCoverageDays());assertEquals("UNIT_UNKNOWN_OR_CONFLICT",row.salesCoverage());assertFalse(row.observationEligible()); + when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93500),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); + var later=at(NOW.plusSeconds(200)).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options()); + assertEquals("STALE",later.context().inventoryStatus());assertNull(later.records().get(0).inventoryCoverageDays());assertNull(later.records().get(0).availableQuantity()); + } + @Test void fixedMetricWindowsNeverFollowChartPeriods(){ + var response=service.inventoryCoverage(metrics(null),options()); var row=response.records().get(0); + assertEquals(n("60.0"),row.inventoryCoverageDays()); assertEquals(0,n("5").compareTo(row.averageDailyOutboundQuantity())); + assertEquals(n("300"),row.availableQuantity()); assertEquals(n("20"),row.frozenQuantity()); assertEquals(n("30"),row.transitQuantity()); + assertEquals(D.minusDays(40),row.observedHistoryStartDate()); + assertEquals(30,response.context().calculationWindowDays()); assertEquals(D.minusDays(30),response.context().rangeStartDate()); + var hot=service.hotSkus(metrics(7)); assertEquals(7,hot.context().calculationWindowDays()); assertEquals(D.minusDays(7),hot.context().rangeStartDate()); + assertThrows(BizException.class,()->service.hotSkus(metrics(15))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(30),options())); + assertThrows(BizException.class,()->service.slowSkus(chart("TODAY",null,null,null,null))); + assertThrows(BizException.class,()->service.hotSkus(chart("LAST_3_DAYS",null,null,null,null))); + } + @Test void unknownInventoryDoesNotReturnZeroCoverage(){ + doReturn(List.of(inventory(true,null,true,time(D.minusDays(40)),"150"))).when(mapper).inventoryPage(any(),any()); + var row=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertNull(row.inventoryCoverageDays());assertNull(row.availableQuantity());assertEquals("UNKNOWN",row.inventoryQuality()); + } + @Test void noObservationEvidenceCannotBecomeThirtyDayDenominatorAndNoSalesIsNotInfinity(){ + doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any()); + var unknown=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("OBSERVATION_INSUFFICIENT",unknown.status()); + doReturn(List.of(inventory(true,"300",true,time(D.minusDays(40)),"0"))).when(mapper).inventoryPage(any(),any()); + var zero=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertEquals("NO_OUTBOUND_30_DAYS",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum()); + } + @Test void rebuildBarrierKeepsVersionButDisablesTrustedMetrics(){ + when(mapper.applyBarriers("demo","W")).thenReturn(1L); var result=service.inventoryCoverage(metrics(null),options()); + assertTrue(result.context().rebuildRequired()); assertEquals("REBUILD_REQUIRED",result.context().salesStatus()); + assertEquals(11L,result.context().salesRunId()); assertNull(result.records().get(0).inventoryCoverageDays()); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper).inventoryPage(cap.capture(),any()); + assertFalse(cap.getValue().completeWindow()); assertFalse(cap.getValue().complete30()); + } + @Test void topOneHundredClipsFinalSqlPageAndSkipsBeyondIt(){ + when(mapper.count(any())).thenReturn(100L); + service.hotSkus(new AnalyticsQuery("demo","W",null,null,null,null,null,null,30,2,75,11L,22L,SH,IH,NOW)); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper).page(cap.capture()); + assertEquals(75,cap.getValue().offset()); assertEquals(25,cap.getValue().size()); + assertTrue(service.hotSkus(new AnalyticsQuery("demo","W",null,null,null,null,null,null,30,3,50,11L,22L,SH,IH,NOW)).records().isEmpty()); + verify(mapper,times(1)).page(any()); + } + @Test void readRunsWithinConsistentSnapshot(){ + var t=AnalyticsQueryService.class.getAnnotation(Transactional.class); assertTrue(t.readOnly()); assertEquals(Isolation.REPEATABLE_READ,t.isolation()); + } + @Test void overviewPreservesUnknownDenominatorAndDoesNotReadCurrentPage(){ + Map totals=new HashMap<>();totals.put("sku_count",3L); + for(String f:List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound")){ + totals.put(f+"_known",n("100"));totals.put(f+"_unknown",0L); + } + totals.put("physical_quantity_unknown",1L);totals.put("physical_sku_known",n("2")); + totals.put("no_outbound_count",1L);totals.put("no_outbound_physical",n("40"));totals.put("no_outbound_unknown",1L); + totals.put("no_outbound_unknown_physical",n("20"));totals.put("above_threshold_count",0L);totals.put("above_threshold_physical",n("0"));totals.put("above_threshold_unknown",3L); + when(mapper.ownerTotals(any(),any())).thenReturn(totals); + var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null); + var response=service.ownerOverview(q,options()); + assertEquals(3L,response.skuCount());assertNull(response.physicalQuantity().overallTotal());assertEquals(n("100"),response.physicalQuantity().knownSubtotal()); + assertNull(response.noOutbound30d().matchedSkuCount());assertNull(response.noOutbound30d().physicalQuantityRatio());assertEquals(1L,response.noOutbound30d().knownMatchedSkuCount()); + assertEquals(n("100"),response.outboundQuantity30d().overallTotal());assertEquals(List.of("THRESHOLD_NOT_SET"),response.coverageAboveThreshold().reasonCodes()); + verify(mapper,never()).page(any());verify(mapper,never()).inventoryPage(any(),any()); + } + @Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){ + var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null); + assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null))); + assertThrows(BizException.class,()->service.ownerOverview(metrics(null),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0))); + verifyNoInteractions(mapper); + } + private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null);} + private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){ + BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales); + return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null, + fresh?n("0"):null,utc(NOW.minusSeconds(3600)),out,out,10L,D.minusDays(1),evidence!=null,out,null,0,0,out==null||stock==null); + } + private AnalyticsQueryService at(Instant now){return new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(now,ZoneOffset.UTC));} + private static AnalyticsQuery chart(String range,LocalDate from,LocalDate until,String owner,String sku){return new AnalyticsQuery("demo","W",owner,sku,range,from,until,"DAY",null,1,20,null,null,null,null,null);} + private static AnalyticsQuery metrics(Integer days){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,days,1,20,null,null,null,null,null);} + private static AnalyticsQuery pinned(String range,long sales,long inventory,String sh,String ih,Instant asOf,int page){return new AnalyticsQuery("demo","W",null,null,range,null,null,range==null?null:"DAY",null,page,20,sales,inventory,sh,ih,asOf);} + private static BigDecimal n(String v){return new BigDecimal(v);} + private static LocalDateTime utc(Instant t){return LocalDateTime.ofInstant(t,ZoneOffset.UTC);} + private static LocalDateTime time(LocalDate date){return utc(date.atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant());} + private static Scope scope(long id,String family,String hash,Long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,utc(NOW.minusSeconds(86400)),utc(NOW.minusSeconds(86400)),7);} + private static Run run(long id,long scope,String hash,Instant observed,Instant superseded,String status,String purpose,String source){ + String config="{\"businessTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600,\"sourceTimezone\":"+(source==null?"null":"\""+source+"\"")+"}"; + return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,config,status,"COMPLETE",time(D.minusDays(40)),utc(observed),D.minusDays(30),D, + utc(observed.minusSeconds(60)),utc(observed),utc(NOW.minusSeconds(3600)),1L,null,null,superseded==null?null:utc(superseded),purpose); + } + private static Profile profile(String unit,String type,LocalDateTime evidence){return new Profile("C","SKU","Product",unit,evidence,evidence,type,1L,1L);} + private static Aggregate aggregate(boolean unit,String available,String quality,LocalDateTime evidence){return new Aggregate("C","SKU","Product","PCS",evidence,unit,n("5"),2L,2,1,0,n("150"),10L,n("150"),n("3"),n("120"),n(available),n("20"),n("30"),utc(NOW.minusSeconds(3600)),quality,null);} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java new file mode 100644 index 000000000..c4c2a38a3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.SqlCommandType; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** MyBatis parses providers and binds parameters; no database or SQL execution. */ +class AnalyticsReadMapperTest { + private static final String NS=AnalyticsReadMapper.class.getName()+"."; + private static Configuration config(){var c=new Configuration(); c.setMapUnderscoreToCamelCase(true); c.addMapper(AnalyticsReadMapper.class); return c;} + private static AnalyticsSqlRequest request(String kind){ + LocalDate d=LocalDate.of(2026,10,12); + return new AnalyticsSqlRequest("demo","W","OWNER","Exact-SKU",11,22,d.minusDays(2),d.plusDays(1),d,d.minusDays(7), + d.minusDays(30),d,LocalDateTime.of(2026,9,11,16,0),LocalDateTime.of(2026,10,12,3,0),true,true,true,true,true,20,20,kind); + } + private static BoundSql bound(Configuration c,String method,Map parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);} + private static void immutableOnly(String sql){ + assertFalse(sql.contains("ll_selfop_outbound_order")); assertFalse(sql.contains("ll_selfop_outbound_item")); assertFalse(sql.contains("ll_selfop_sync_stage")); + assertFalse(sql.contains("${")); assertFalse(sql.contains("Exact-SKU")); assertFalse(sql.contains("OWNER")); + } + @Test void allMapperStatementsAreSelectAndProvidersBindTheNamedRecord(){ + var c=config(); assertNotNull(AnalyticsReadMapper.class.getAnnotation(Mapper.class)); + for(String method:List.of("scopes","publishedRun","page","count","excluded","profile","daily","applyBarriers")){ + var statement=c.getMappedStatement(NS+method); assertEquals(SqlCommandType.SELECT,statement.getSqlCommandType()); + Map params=new HashMap<>(); params.put("p",request("HOT")); params.put("id",11L); params.put("run",11L); + params.put("account","demo"); params.put("warehouse","W"); params.put("customer","OWNER"); params.put("sku","Exact-SKU"); + params.put("from",LocalDate.of(2026,10,10)); params.put("until",LocalDate.of(2026,10,13)); + immutableOnly(statement.getBoundSql(params).getSql()); + } + var b=bound(c,"page",Map.of("p",request("HOT"))); + var names=b.getParameterMappings().stream().map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList(); + for(String property:List.of("p.account","p.warehouse","p.customer","p.sku","p.salesRun","p.inventoryRun","p.from","p.until","p.size","p.offset"))assertTrue(names.contains(property),property); + assertFalse(b.getSql().contains("#{")); + assertEquals(AnalyticsReadRows.Aggregate.class,c.getMappedStatement(NS+"page").getResultMaps().get(0).getType()); + } + @Test void exactDailySqlFiltersEveryIdentityVersionAndHalfOpenDateBeforeAscendingAxis(){ + var c=config(); var b=bound(c,"daily",Map.of("run",11L,"account","demo","warehouse","W","customer","OWNER","sku","Exact-SKU", + "from",LocalDate.of(2026,10,10),"until",LocalDate.of(2026,10,13))); + String sql=b.getSql(); + for(String fragment:List.of("sales_run_id=?","source_account=?","warehouse_code=?","customer_code=?","sku_code=?","business_date>=?","business_dateof("p",request("SUMMARY")); + String count=bound(c,"count",parameters).getSql(); + assertTrue(count.contains(" UNION SELECT customer_code,sku_code")); assertFalse(count.contains("UNION ALL")); + assertFalse(count.contains("ll_selfop_sku_daily_sales")); assertFalse(count.contains("ll_selfop_inventory_snapshot")); + String page=bound(c,"page",parameters).getSql(); + assertTrue(page.contains("d.sales_run_id=?")); assertTrue(page.contains("d.business_date>=CAST(? AS DATE) AND d.business_date=DATE_SUB")); + assertFalse(page.contains("ll_selfop_inventory_snapshot")); + assertTrue(page.endsWith("ORDER BY observed_quantity IS NULL,observed_quantity DESC,sku_code ASC,customer_code ASC LIMIT ? OFFSET ?")); + assertTrue(page.indexOf("GROUP BY b.customer_code,b.sku_code")of("p",request(kind)); String sql=bound(c,"page",p).getSql(); + assertTrue(sql.contains("st.snapshot_run_id=?")); assertTrue(sql.contains("st.source_account=?")); assertTrue(sql.contains("st.warehouse_code=?")); + assertTrue(sql.contains("AND st.product_quality=0")); + assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date0 ORDER BY window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC LIMIT")); + String slow=bound(c,"page",Map.of("p",request("SLOW"))).getSql(); + assertTrue(slow.contains("inventory_quality='VALID' AND available_quantity>0")); + assertTrue(slow.contains("evidence_at_utc<=CONVERT_TZ")); + assertTrue(slow.contains("ORDER BY window_quantity ASC,available_quantity DESC,sku_code ASC,customer_code ASC LIMIT")); + for(String kind:List.of("HOT","SLOW")){ + String count=bound(c,"count",Map.of("p",request(kind))).getSql(); assertTrue(count.contains("SELECT LEAST(COUNT(*),100) FROM result")); assertFalse(count.contains(" LIMIT ")); + } + } + @Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){ + String sql=bound(config(),"page",Map.of("p",request("INVENTORY"))).getSql(); + assertTrue(sql.contains("s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL")); + assertTrue(sql.contains("i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL")); + assertFalse(sql.contains("first_seen_at_utc")); assertTrue(sql.contains("COALESCE(s.quantity_unit,i.quantity_unit)<>'PCS'")); + assertTrue(sql.contains("s.quantity_unit<>i.quantity_unit")); + // Comparing timestamps before DATE projection prevents 16:00 evidence from + // turning an 08:00 covered-until on the same day into an observed zero. + assertTrue(sql.contains("AND GREATEST(evidence_at_utc,CAST(? AS DATETIME))0 THEN COALESCE(c.observed_sum,0) END observed_quantity")); + assertTrue(sql.contains("WHEN ?=0 THEN 'STALE'")); + assertTrue(sql.contains("st.available_quantity<0")); assertTrue(sql.contains("st.available_quantity IS NULL")); + } + @Test void publishedRunRejectsValidationAndAccountDiscoveryAndBarrierSurvivesDeadTask(){ + var c=config(); String run=bound(c,"publishedRun",Map.of("id",11L)).getSql(); + assertTrue(run.startsWith("SELECT id,task_id,scope_id,")); assertTrue(run.contains("status='PUBLISHED'")); + assertTrue(run.contains("run_role='WAREHOUSE_SYNC'")); assertTrue(run.contains("execution_purpose='BUSINESS'")); + assertEquals(AnalyticsReadRows.Run.class,c.getMappedStatement(NS+"publishedRun").getResultMaps().get(0).getType()); + String barrier=bound(c,"applyBarriers",Map.of("account","demo","warehouse","W")).getSql(); + assertTrue(barrier.contains("'APPLYING','BLOCKED'")); assertTrue(barrier.contains("JSON_CONTAINS")); assertFalse(barrier.contains("status=")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java new file mode 100644 index 000000000..db52e1525 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java @@ -0,0 +1,251 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.*; +import java.math.BigDecimal; +import java.sql.*; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Executes the production provider SQL on synthetic, process-local H2 tables only. + * H2's CONVERT_TZ is supplied with explicit offsets. Its repeatedly referenced CTE prepared + * plan loses parameter values, so page SQL uses typed, escaped fixture literals from the + * original BoundSql mappings. Candidate counts and separate binding contracts remain prepared. + * No external database, credentials or HTTP; MySQL plans/performance remain separate gates. */ +public class AnalyticsSqlSemanticTest { + private Connection schema; + private SqlSession session; + private AnalyticsReadMapper mapper; + private Configuration configuration; + private int statementSequence; + private long nextId=1; + private static final LocalDate D=LocalDate.of(2026,10,12); + @BeforeEach void setup() throws Exception { + String url="jdbc:h2:mem:selfop_query_"+UUID.randomUUID().toString().replace("-","")+";MODE=MySQL"; + var ds=new UnpooledDataSource("org.h2.Driver",url,"sa",""); + schema=ds.getConnection(); + try(var statement=schema.createStatement()) { + statement.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'"); + statement.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); + statement.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))"); + statement.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))"); + } + configuration=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds)); + configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AnalyticsReadMapper.class); + session=new SqlSessionFactoryBuilder().build(configuration).openSession();mapper=session.getMapper(AnalyticsReadMapper.class); + } + @AfterEach void close() throws Exception {if(session!=null)session.close();if(schema!=null)schema.close();} + public static LocalDateTime convertZone(LocalDateTime value,String from,String to) { + return value==null?null:value.atOffset(ZoneOffset.of(from)).withOffsetSameInstant(ZoneOffset.of(to)).toLocalDateTime(); + } + @Test void sqlReturnsTwoCompleteDaysAndTodayPartialObservedFive() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(35),0),"PCS"); + daily(11,"W","C","SKU",D.minusDays(2),"2");daily(11,"W","C","SKU",D,"3"); + var p=request("SUMMARY",D.minusDays(2),D.plusDays(1),null,null,utc(D,8),0,20); + assertEquals(1,mapper.count(p));var row=page(p).get(0); + assertEquals(0,new BigDecimal("5").compareTo(row.observedQuantity())); + assertEquals(2,row.completeDayCount());assertEquals(1,row.partialDayCount());assertEquals(0,row.unknownDayCount()); + } + @Test void evidenceAfterCoveredTimeOnSameDayDoesNotBecomeObservedZero() throws Exception { + profile(22,"C","LATE","INVENTORY_OBSERVATION",utc(D,16),"PCS"); + profile(22,"C","NO-EVIDENCE","NONE",null,"PCS"); + var rows=page(request("SUMMARY",D,D.plusDays(1),null,null,utc(D,8),0,20)); + var unknown=rows.stream().filter(r->"NO-EVIDENCE".equals(r.skuCode())).findFirst().orElseThrow(); + assertNull(unknown.observedQuantity());assertEquals(1,unknown.unknownDayCount()); + var row=rows.stream().filter(r->"LATE".equals(r.skuCode())).findFirst().orElseThrow(); + assertNull(row.observedQuantity());assertEquals(0,row.completeDayCount());assertEquals(0,row.partialDayCount());assertEquals(1,row.unknownDayCount()); + // A real confirmed row outside the continuous watermark is still observed, never complete today. + daily(11,"W","C","LATE",D,"3");session.clearCache(); + row=page(request("SUMMARY",D,D.plusDays(1),null,null,utc(D,8),0,20)).get(0); + assertEquals(0,new BigDecimal("3").compareTo(row.observedQuantity()));assertEquals(1,row.partialDayCount()); + } + @Test void intervalSortingIncludesZeroCandidatesBeforePagingAndNeverMergesOwners() throws Exception { + for(String owner:List.of("A","B","C"))profile(22,owner,"SAME","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","A","SAME",D.minusDays(1),"2");daily(11,"W","B","SAME",D.minusDays(1),"5"); + var first=request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),0,1); + assertEquals(3,mapper.count(first));assertEquals("B",page(first).get(0).customerCode()); + assertEquals("A",page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),1,1)).get(0).customerCode()); + var zero=page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),2,1)).get(0); + assertEquals("C",zero.customerCode());assertEquals(0,zero.observedQuantity().signum());assertEquals(1,zero.completeDayCount()); + } + @Test void exactFiltersExcludeOtherWarehouseVersionDatesAndQualityNeverMultipliesSales() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","C","SKU",D.minusDays(1),"150");daily(11,"OTHER","C","SKU",D.minusDays(1),"999"); + daily(33,"W","C","SKU",D.minusDays(1),"999");daily(11,"W","C","SKU",D.minusDays(31),"999"); + stock("C","SKU",0,"300");stock("C","SKU",1,"999"); + var row=page(request("INVENTORY",D.minusDays(30),D,"C","SKU",utc(D,8),0,20)).get(0); + assertEquals(0,new BigDecimal("150").compareTo(row.sales30Quantity())); + assertEquals(0,new BigDecimal("300").compareTo(row.availableQuantity()));assertEquals(1L,row.windowSkuOrderCount()); + assertEquals(0,mapper.count(request("SUMMARY",D.minusDays(1),D,"OTHER","SKU",utc(D,8),0,20))); + } + @Test void inventoryUnitConflictPreservesPureSalesButBlocksMixedMetricEligibility() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");daily(11,"W","C","SKU",D.minusDays(1),"5"); + profile(11,"C","UNKNOWN-UNIT","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","UNKNOWN-UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),null); + daily(11,"W","C","UNKNOWN-UNIT",D.minusDays(1),"5"); + var rows=page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),0,20)); + assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0)); + var row=rows.get(0); + assertTrue(row.unitValid());assertEquals(0,row.unknownDayCount()); + assertTrue(page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)).isEmpty()); + } + @Test void inventoryQualitiesAggregateBeforeSalesAndKeepOnlyGoodAvailable() throws Exception { + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","C","SKU",D.minusDays(1),"150"); + stockExact("SKU",0,"300","250","20","30","VALID",null); + stockExact("SKU",1,"60","50","5","10","VALID",null); + var row=inventory(null,null,null,0,20).get(0); + assertDecimal("360",row.physicalQuantity());assertDecimal("250",row.availableQuantity());assertDecimal("60",row.defectivePhysicalQuantity()); + assertDecimal("25",row.frozenQuantity());assertDecimal("40",row.transitQuantity());assertDecimal("150",row.outboundQuantity30d()); + assertEquals(D.minusDays(1),row.lastOutbound());assertEquals(1L,row.orderCount()); + } + @Test void quantityUnknownIsDependencySpecificAndUnknownQualityIsNotDefective() throws Exception { + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact("SKU",0,"20","10",null,"2","UNKNOWN","FIELD_MISSING"); + stockExact("SKU",7,"3","0","0","0","VALID",null); + var row=inventory(null,null,null,0,20).get(0); + assertDecimal("23",row.physicalQuantity());assertDecimal("10",row.availableQuantity());assertNull(row.frozenQuantity()); + assertNull(row.defectivePhysicalQuantity());assertEquals(1,row.noOutbound());assertNull(row.coverageDays()); + assertEquals(1,inventory("NO_OUTBOUND_30D",null,null,0,20).size()); + } + @Test void frozenDefectiveOnlyStockMatchesNoOutboundEvenWhenGoodAvailableIsZero() throws Exception { + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact("SKU",0,"0","0","0","0","VALID",null);stockExact("SKU",1,"40","0","40","0","VALID",null); + var row=inventory("NO_OUTBOUND_30D",null,null,0,20).get(0); + assertDecimal("0",row.coverageDays());assertEquals(1,row.noOutbound());assertEquals(1,inventory("FROZEN_STOCK",null,null,0,20).size()); + assertEquals(1,inventory("DEFECTIVE_STOCK",null,null,0,20).size());assertTrue(inventory("COVERAGE_GT_THRESHOLD",1,null,0,20).isEmpty()); + } + @Test void thresholdUsesExactCrossProductBeforePagingAndOwnerTotalsUseAllCandidates() throws Exception { + for(int i=0;i<105;i++){ + String sku=String.format("SKU-%03d",i);profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact(sku,0,"301","301","0","0","VALID",null);daily(11,"W","C",sku,D.minusDays(1),"30"); + } + profile(22,"C","BOUNDARY","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact("BOUNDARY",0,"300.000001","300.000001","0","0","VALID",null);daily(11,"W","C","BOUNDARY",D.minusDays(1),"30"); + var rows=inventory("COVERAGE_GT_THRESHOLD",300,"PHYSICAL_DESC",100,20);assertEquals(6,rows.size()); + assertEquals("BOUNDARY",rows.get(5).skuCode());assertDecimal("300.000001",rows.get(5).coverageDays()); + var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),100,20); + var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300); + assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue()); + Map totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue()); + assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known")); + } + @Test void ownerSalesCompleteDoesNotRequireEachSkuObservationAndRiskUnknownIsRetained() throws Exception { + profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null); + daily(11,"W","C","NEW",D.minusDays(1),"5"); + var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound()); + Map totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30)); + assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue()); + assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical")); + } + @Test void ownersAreCaseSensitiveDistinctAndPrefixWildcardsAreLiteral() throws Exception { + for(String owner:List.of("A%","A_test","ABC","abc")){ + profile(11,owner,"SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");profile(22,owner,"SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + } + var p=request("OWNERS",D.minusDays(30),D,null,null,utc(D,8),0,20); + assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null))); + assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null))); + assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null))); + assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null)).get(0)).longValue()); + } + @Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX"); + daily(11,"W","C","SKU",D.minusDays(1),"12");stockExact("SKU",0,"30","30","0","0","VALID",null); + var row=inventory(null,30,null,0,20).get(0);assertDecimal("12",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.physicalQuantity()); + var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30)); + assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue()); + } + private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));} + @SuppressWarnings("unchecked") + private List inventory(String attention,Integer threshold,String sort,long offset,int size){ + return (List)(List)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold)); + } + @SuppressWarnings("unchecked") + private Map totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + Map result=new HashMap<>();((Map)literalQuery("ownerTotals",p,o).get(0)).forEach((k,v)->result.put(k.toLowerCase(Locale.ROOT),v));return result; + } + private List literalQuery(String method,AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + String namespace=AnalyticsReadMapper.class.getName()+"."+method;var original=configuration.getMappedStatement(namespace); + Map args=Map.of("p",p,"o",o);String literal=render(original.getBoundSql(args),args);String id=namespace+".fixture."+(++statementSequence); + configuration.addMappedStatement(new org.apache.ibatis.mapping.MappedStatement.Builder(configuration,id,new org.apache.ibatis.builder.StaticSqlSource(configuration,literal), + org.apache.ibatis.mapping.SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id); + } + private void stockExact(String sku,int quality,String physical,String available,String frozen,String transit,String status,String reason)throws Exception{ + try(var p=schema.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){ + p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,quality); + String[] quantities={physical,available,frozen,transit};for(int i=0;i<4;i++)p.setBigDecimal(7+i,quantities[i]==null?null:new BigDecimal(quantities[i])); + p.setObject(11,utc(D,8));p.setString(12,status);p.setString(13,reason);p.executeUpdate(); + } + } + private List page(AnalyticsSqlRequest request) { + String namespace=AnalyticsReadMapper.class.getName()+".page"; + var original=configuration.getMappedStatement(namespace); + Map parameters=Map.of("p",request); + var bound=original.getBoundSql(parameters); + String literal=render(bound,parameters); + String id=namespace+".syntheticLiteral."+(++statementSequence); + var source=new org.apache.ibatis.builder.StaticSqlSource(configuration,literal); + configuration.addMappedStatement(new org.apache.ibatis.mapping.MappedStatement.Builder(configuration,id,source, + org.apache.ibatis.mapping.SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build()); + return session.selectList(id); + } + private static String render(org.apache.ibatis.mapping.BoundSql bound,Map parameters) { + var meta=org.apache.ibatis.reflection.SystemMetaObject.forObject(parameters); + String sql=bound.getSql();StringBuilder result=new StringBuilder();int index=0;boolean quoted=false; + for(int i=0;iservice.tasks(q(2,null,null))); + assertThrows(BizException.class,()->service.tasks(q(1,now,null))); + verify(mapper,never()).tasks(any(),any()); + } + @Test void dailyInventoryCountsCompletedBeijingDaysAndPreservesGapsPerWarehouse() { + config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse()); + service=new CollectionQueryService(mapper,config,new ObjectMapper(),Clock.fixed(Instant.parse("2026-10-11T16:01:00Z"),ZoneOffset.UTC)); + LocalDate until=LocalDate.of(2026,10,12),from=until.minusDays(30); + when(mapper.inventoryDailySamples("primary",List.of("A","B"),from,until)).thenReturn(List.of( + new InventorySample("A",until.minusDays(1)),new InventorySample("A",until.minusDays(2)), + new InventorySample("B",until.minusDays(3)))); + when(mapper.inventoryAccumulationStarts("primary",List.of("A","B"))).thenReturn(List.of( + new InventorySample("A",until.minusDays(40)),new InventorySample("B",until.minusDays(3)))); + var rows=service.overview(new CollectionQuery("primary",null,null,null,null,null,null,null,null,null,null,null)).warehouses(); + var a=rows.stream().filter(w->w.warehouseCode().equals("A")&&w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples(); + var b=rows.stream().filter(w->w.warehouseCode().equals("B")&&w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples(); + assertEquals(from,a.windowFrom());assertEquals(until,a.windowUntil());assertEquals(30,a.expectedDays()); + assertEquals(2,a.sampleCount());assertEquals(28,a.missingDates().size());assertFalse(a.missingDates().contains(until.minusDays(1))); + assertEquals(until.minusDays(40),a.accumulationStartedOn());assertEquals(1,b.sampleCount()); + assertTrue(rows.stream().filter(w->w.dataFamily().equals("OUTBOUND")).allMatch(w->w.inventoryDailySamples()==null)); + verify(mapper,times(1)).inventoryDailySamples("primary",List.of("A","B"),from,until); + } + @Test void noInventorySamplesShowsThirtyGapsWithoutInventingHistory() { + var row=service.overview(q(null,null,null)).warehouses().stream() + .filter(w->w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples(); + assertEquals(0,row.sampleCount());assertEquals(30,row.missingDates().size());assertNull(row.accumulationStartedOn()); + } + @Test void accountAndWarehouseAreValidatedBeforePersistenceRead() { + assertThrows(BizException.class,()->service.tasks(new CollectionQuery("other","A",null,null,null,null,null,null,null,null,null,null))); + assertThrows(BizException.class,()->service.tasks(new CollectionQuery("primary",null,null,null,null,null,null,null,null,null,null,null))); + verifyNoInteractions(mapper); + } + @Test void frozenBoundAndBeijingDateFiltersGoToSql() { + Bound upper=new Bound(now,99L); + when(mapper.tasks(any(),eq(upper))).thenReturn(List.of()); + var query=new CollectionQuery("primary","A","OUTBOUND",null,null,null,LocalDate.of(2026,10,12),LocalDate.of(2026,10,12),2,20,now.toInstant(ZoneOffset.UTC),99L); + var result=service.tasks(query); + ArgumentCaptor f=ArgumentCaptor.forClass(CollectionReadMapper.Filter.class); + verify(mapper).tasks(f.capture(),eq(upper)); + assertEquals(LocalDateTime.of(2026,10,11,16,0),f.getValue().from()); + assertEquals(LocalDateTime.of(2026,10,12,16,0),f.getValue().until()); + assertEquals(20,f.getValue().offset());assertEquals(99L,result.listUpperId()); + verify(mapper,never()).taskBound(any()); + } + @Test void runHistoryReadsFrozenTerminalProgressRatherThanTaskCurrentAttempt() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED","{\"completed\":2,\"totalKnown\":false,\"unit\":\"ORDER\"}"))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS","{\"completed\":99}",2))); + var result=service.runs(q(null,null,null)); + assertEquals(2L,result.records().get(0).progress().get("completed")); + assertEquals("LXCOL-TASK",result.records().get(0).taskNo());assertEquals(90L,result.records().get(0).runId()); + assertEquals("TERMINATED",result.records().get(0).executionHealth()); + } + @Test void activeRunFromDifferentAttemptDoesNotReadNewAttemptProgress() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FETCHING",null))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS","{\"completed\":99}",2))); + assertTrue(service.runs(q(null,null,null)).records().get(0).progress().isEmpty()); + } + @Test void progressWhitelistPreservesUnknownCountsAndNeverCreatesTotalPercentage() { + var result=service.progress("{\"completed\":3,\"total\":17,\"totalKnown\":false,\"unknownCount\":null,\"unit\":\"ORDER\",\"secret\":\"hidden\",\"percent\":90}"); + assertEquals(3L,result.get("completed"));assertNull(result.get("total"));assertNull(result.get("unknownCount")); + assertFalse(result.containsKey("secret"));assertFalse(result.containsKey("percent")); + assertTrue(service.progress("{\"x\":\""+"x".repeat(8192)+"\"}").isEmpty()); + assertTrue(service.progress("not json").isEmpty()); + } + @Test void unknownUnitsAndNegativeCountsAreNotTrusted() { + var result=service.progress("{\"completed\":-1,\"unit\":\"HTTP\",\"total\":3,\"totalKnown\":true}"); + assertNull(result.get("completed"));assertNull(result.get("unit"));assertEquals(3L,result.get("total")); + } + @Test void batchCheckpointsKeepReliableCountsWhileNonCountingStagesNeverPretendZeroProgress() { + var batch=service.progress("{\"unit\":\"BATCH\",\"completed\":3,\"total\":5,\"totalKnown\":true}"); + assertEquals("BATCH",batch.get("unit"));assertEquals(3L,batch.get("completed"));assertEquals(5L,batch.get("total")); + var waiting=service.progress("{\"unit\":\"NONE\",\"completed\":0,\"total\":0,\"totalKnown\":true}"); + assertEquals("NONE",waiting.get("unit"));assertNull(waiting.get("completed"));assertNull(waiting.get("total")); + assertEquals(false,waiting.get("totalKnown")); + } + @Test void validationTaskCanNeverAdvertiseBusinessRetry() { + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(task("VALIDATION",null,0)); + var result=service.taskDetail("LXCOL-TASK",q(null,null,null)); + assertTrue(result.task().allowedActions().isEmpty());assertEquals("VALIDATION_NOT_RETRYABLE",result.task().actionBlockReason()); + } + @Test void emptyPageReturnsFrozenEmptyUpperBound() { + when(mapper.taskBound(any())).thenReturn(null); + var result=service.tasks(q(null,null,null)); + assertEquals(0L,result.listUpperId());assertEquals(now.toInstant(ZoneOffset.UTC),result.listUpperCreatedAt());assertEquals(0,result.total()); + verify(mapper,never()).tasks(any(),any()); + } + @Test void missingTaskDoesNotLeakOtherWarehouseTask() { + assertThrows(BizException.class,()->service.taskDetail("LXCOL-OTHER",q(null,null,null))); + } + @Test void repeatedRowsUseOneBatchLookupAndNeverFetchPerTask() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED",null),run(2,"FETCHING",null))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS",null,2))); + service.runs(q(null,null,null)); + verify(mapper,times(1)).tasksByIds("primary",List.of(7L)); + } + @Test void errorPayloadIsNeverExposedToOperationsPage() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + var r=new Attempt(90L,7L,"LXCOL-TASK",1,"FAILED","DETAILS","BUSINESS",now.minusHours(1),null,null, + now,null,now,null,"PARTIAL",null,null,"SOURCE_ERROR","secret=credential; customer private data",now,"A",1L,"hash"); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(r)); + var result=service.runs(q(null,null,null)).records().get(0); + assertEquals("SOURCE_ERROR",result.errorCode());assertFalse(result.failureSummary().contains("credential")); + } + @Test void inventoryFreshnessUsesCompletedObservationAndBarrierBlocksInitialCoverage() { + Scope s=new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600, + now.minusMonths(2),now,now.minusMonths(2),now,0,90L,now.minusDays(8),now,7,null,null); + when(mapper.scopes("primary","A")).thenReturn(List.of(s)); + when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-TASK",1,"PUBLISHED","PUBLISH","BUSINESS", + now.minusDays(5),now.minusMinutes(5),null,now.minusMinutes(5),now.minusMinutes(5),null,null,"COMPLETE", + now.minusMonths(2),now,null,null,now.minusDays(5),"A",1L,"hash"))); + when(mapper.barrierWarehouses(eq("primary"),anyList())).thenReturn(List.of("A")); + var row=service.overview(q(null,null,null)).warehouses().get(0); + assertEquals("PARTIAL",row.initialCoverageStatus());assertEquals("FRESH",row.dataFreshness()); + assertTrue(row.rebuildRequired());assertEquals("OVERDUE",row.revisionReviewStatus()); + } + @Test void validationBudgetReadsActualFlatPersistedFieldsAndMasksDetails() { + Task t=new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","VALIDATION","VALIDATION", + "hash","SAMPLE_DONE","NOT_APPLICABLE",1,null,null,null,null,null,null,null,null, + "{\"maxPages\":10,\"pages\":3,\"http\":5,\"confirmedHttp\":4,\"detailKeys\":[\"secret-order\"],\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",now,now); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(t); + var result=service.taskDetail("LXCOL-TASK",q(null,null,null)).task().validation(); + assertEquals(10L,result.budgetLimits().get("maxPages"));assertEquals(3L,result.budgetConsumed().get("pages")); + assertEquals(5L,result.budgetConsumed().get("http"));assertEquals(1L,result.budgetConsumed().get("distinctDetails")); + assertNull(result.budgetConsumed().get("detailHttp"));assertFalse(result.budgetConsumed().containsKey("detailKeys")); + assertEquals(Instant.parse("2026-10-12T04:05:00Z"),result.deadlineAt()); + } + @Test void allMapperSqlParsesOfflineAndRunQueryOnlyReadsLightweightTables() { + var configuration=new org.apache.ibatis.session.Configuration(); + configuration.addMapper(CollectionReadMapper.class); + var f=new CollectionReadMapper.Filter("primary","A","OUTBOUND","SCHEDULED","FAILED","LXCOL-TASK",now.minusDays(1),now,20,20); + var parameters=new HashMap();parameters.put("f",f);parameters.put("b",new Bound(now,99L)); + parameters.put("account","primary");parameters.put("warehouse","A");parameters.put("taskNo","LXCOL-TASK"); + parameters.put("ids",List.of(1L,7L));parameters.put("warehouses",List.of("A")); + parameters.put("from",LocalDate.of(2026,9,12));parameters.put("until",LocalDate.of(2026,10,12)); + for(String method:List.of("taskBound","runBound","taskCount","tasks","runCount","runs","tasksByIds","latestAttempts", + "publishedRuns","currentTasks","lastSuccessfulTasks","sharedActiveAccount","barrierWarehouses", + "inventoryDailySamples","inventoryAccumulationStarts")) { + String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql(); + assertFalse(sql.contains("routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot")); + assertFalse(sql.contains("ll_selfop_sync_stage")); + } + String runSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".runs").getBoundSql(parameters).getSql(); + assertTrue(runSql.contains("r.created_at <"));assertTrue(runSql.contains("r.id <="));assertTrue(runSql.contains("LIMIT ? OFFSET ?")); + String samplesSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".inventoryDailySamples").getBoundSql(parameters).getSql(); + assertTrue(samplesSql.contains("r.inventory_daily_sample_date >= ?")); + assertTrue(samplesSql.contains("r.inventory_daily_sample_date < ?")); + assertTrue(samplesSql.contains("r.status='PUBLISHED'"));assertTrue(samplesSql.contains("r.execution_purpose='BUSINESS'")); + assertTrue(samplesSql.contains("s.data_family='INVENTORY'")); + } + @Test void checkpointTimeIsNotInventedFromAttemptFailureTimeAndOffCannotAdvertiseRetry() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED", + "{\"stageStartedAt\":\"2026-10-12T02:00:00Z\",\"summaryAsOf\":\"2026-10-12T02:30:00Z\"}"))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS",null,1))); + var row=service.runs(q(null,null,null)).records().get(0); + assertEquals(Instant.parse("2026-10-12T02:00:00Z"),row.phaseStartedAt()); + assertEquals(Instant.parse("2026-10-12T02:30:00Z"),row.progressUpdatedAt());assertFalse(row.retryable()); + } + @Test void inventoryFullSnapshotDoesNotWaitForSalesInitialCoverageOrApplyBarrier() { + Scope inventory=new Scope(2L,"A","INVENTORY","hash","Asia/Shanghai","0 0 8 * * *",93600, + null,null,null,null,0,90L,null,null,7,null,null); + when(mapper.scopes("primary","A")).thenReturn(List.of(scope(),inventory)); + when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-STOCK",1, + "PUBLISHED","PUBLISH","BUSINESS",now.minusHours(1),now.minusMinutes(1),null,now,now,null,null, + "COMPLETE",null,null,null,null,now.minusHours(1),"A",2L,"hash"))); + when(mapper.barrierWarehouses(eq("primary"),anyList())).thenReturn(List.of("A")); + var row=service.overview(q(null,null,null)).warehouses().stream().filter(w->"INVENTORY".equals(w.dataFamily())).findFirst().orElseThrow(); + assertEquals("COMPLETE",row.initialCoverageStatus());assertFalse(row.rebuildRequired());assertEquals("FRESH",row.dataFreshness()); + } + @Test void anOldScopeHashCannotProveCurrentInitialWindowComplete() { + Scope scope=new Scope(1L,"A","OUTBOUND","NEW-HASH","Asia/Shanghai","0 0 8,20 * * *",93600, + now.minusMonths(2),now,now.minusMonths(2),now,0,90L,null,null,7,null,null); + when(mapper.scopes("primary","A")).thenReturn(List.of(scope)); + when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-TASK",1, + "PUBLISHED","PUBLISH","BUSINESS",now.minusHours(1),now,null,now,now,null,null,"COMPLETE", + now.minusMonths(2),now,null,null,now.minusHours(1),"A",1L,"OLD-HASH"))); + assertEquals("PARTIAL",service.overview(q(null,null,null)).warehouses().get(0).initialCoverageStatus()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java new file mode 100644 index 000000000..df4467a3f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; +class SelfopEgressControllerTest { + @Test void invalidInternalTokenCannotReachAuthority(){ + var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); + assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401)); + assertThatThrownBy(()->c.complete(null,null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401)); + verifyNoInteractions(service); + } + @Test void bothCallbacksUseGuardThenAuthority(){var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var r=new SelfopEgressDtos.AuthorizationRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64));c.authorize("synthetic",r);verify(s).authorize(r);var complete=new SelfopEgressDtos.CompletionRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64),1,null,200,200,false,null);assertThat(c.complete("synthetic",complete).data()).isTrue();verify(s).complete(complete);} + @Test void denialUsesExactB0001DataEnvelopeWithInstantOrExplicitNull() throws Exception { + var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s); + doThrow(new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,java.time.LocalDateTime.of(2026,10,12,0,0,5))).when(s).authorize(null); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + var json=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",null))); + assertThat(json.path("code").asText()).isEqualTo("B0001");assertThat(json.path("data").path("errorCode").asText()).isEqualTo("ACCOUNT_INTERVAL_WAIT");assertThat(json.path("data").path("waitUntil").asText()).isEqualTo("2026-10-12T00:00:05Z");assertThat(json.path("data").path("sampleDone").booleanValue()).isFalse(); + doThrow(new EgressRejectedException("VALIDATION_HTTP_LIMIT",true,null)).when(s).authorize(null);json=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",null)));assertThat(json.path("data").has("waitUntil")).isTrue();assertThat(json.path("data").path("waitUntil").isNull()).isTrue();assertThat(json.path("data").path("sampleDone").booleanValue()).isTrue(); + } + @Test void authorityDtosRoundTripWithIsoInstantAndExplicitNullableOutcome() throws Exception { + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var operation=SelfopEgressDtos.Operation.WAREHOUSES;String hash="1".repeat(64);var expiry=java.time.Instant.parse("2026-10-12T00:00:30Z"); + var permit=new SelfopEgressDtos.AttemptPermit(context,operation,hash,1,expiry,true,true);String permitJson=mapper.writeValueAsString(permit);assertThat(mapper.readTree(permitJson).path("expiresAt").asText()).isEqualTo("2026-10-12T00:00:30Z");assertThat(mapper.readValue(permitJson,SelfopEgressDtos.AttemptPermit.class)).isEqualTo(permit); + var request=new SelfopEgressDtos.AuthorizationRequest(context,operation,hash);assertThat(mapper.readValue(mapper.writeValueAsString(request),SelfopEgressDtos.AuthorizationRequest.class)).isEqualTo(request); + var complete=new SelfopEgressDtos.CompletionRequest(context,operation,hash,1,"LINGXING_WMS_SOURCE_RATE_LIMITED",200,31001,true,expiry);assertThat(mapper.readValue(mapper.writeValueAsString(complete),SelfopEgressDtos.CompletionRequest.class)).isEqualTo(complete); + var nullable=new SelfopEgressDtos.CompletionRequest(context,operation,hash,1,null,null,null,false,null);var node=mapper.readTree(mapper.writeValueAsString(nullable));assertThat(node.path("sourceCode").isNull()).isTrue();assertThat(node.path("upstreamStatus").isNull()).isTrue();assertThat(mapper.readValue(node.toString(),SelfopEgressDtos.CompletionRequest.class)).isEqualTo(nullable); + var denial=new SelfopEgressDtos.AuthorizationDenial("ACCOUNT_DAILY_BUDGET",false,expiry);assertThat(mapper.readValue(mapper.writeValueAsString(denial),SelfopEgressDtos.AuthorizationDenial.class)).isEqualTo(denial); + var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);when(s.authorize(request)).thenReturn(permit);var envelope=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",request)));assertThat(envelope.path("data")).isEqualTo(mapper.readTree(permitJson));assertThat(envelope.path("data").path("budgetConsumed").booleanValue()).isTrue(); + } + @Test void conflictingCompletionPropagatesRejectionInsteadOfSuccessfulAcknowledgment(){ + var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var completion=new SelfopEgressDtos.CompletionRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64),1,null,200,200,false,null); + doThrow(new EgressRejectedException("EGRESS_COMPLETION_CONFLICT",false,null)).when(s).complete(completion);assertThatThrownBy(()->c.complete("synthetic",completion)).isInstanceOf(EgressRejectedException.class).hasMessage("EGRESS_COMPLETION_CONFLICT");verify(s).complete(completion); + } + @Test void durableQueriesUseCurrentReadsAndActualColumnNames()throws Exception{for(String name:new String[]{"lockAccount","lockTask","lockRun"}){var m=java.util.Arrays.stream(SelfopEgressMapper.class.getMethods()).filter(x->x.getName().equals(name)).findFirst().orElseThrow();assertThat(String.join(" ",m.getAnnotation(org.apache.ibatis.annotations.Select.class).value())).contains("FOR UPDATE");}assertThat(SelfopEgressMapper.class.isAnnotationPresent(org.apache.ibatis.annotations.Mapper.class)).isTrue();} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java new file mode 100644 index 000000000..418b13912 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -0,0 +1,170 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.*; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class SelfopEgressServiceTest { + private final SelfopEgressMapper mapper=mock(SelfopEgressMapper.class); + private final SelfopAnalyticsProperties cfg=new SelfopAnalyticsProperties(); + private final MutableClock clock=new MutableClock(); + private final SelfopOutboundDiscoveryScope account=new SelfopOutboundDiscoveryScope(); + private final SelfopSyncTask task=new SelfopSyncTask();private final SelfopSyncRun run=new SelfopSyncRun(); + private final SelfopEgressService service=new SelfopEgressService(mapper,cfg,clock); + private static final String INTERNAL="1".repeat(64),PROVIDER="2".repeat(64); + SelfopEgressServiceTest(){ + cfg.setSourceAccount("synthetic");cfg.setCollectionMode("VALIDATION");cfg.setExclusiveGrantId("grant");cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2));cfg.setExclusiveHandoffVerified(true);cfg.setPriorEgressClosed(true);cfg.setInflightDrained(true);cfg.setTransferredBudgetVerified(true);cfg.setTransferredBudgetDate(now().plusHours(8).toLocalDate());cfg.setDailyRequestBudget(100);cfg.getWarehouses().put("A",new SelfopAnalyticsProperties.Warehouse());cfg.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse()); + account.setId(10L);account.setSourceAccount("synthetic");account.setActiveTaskId(1L);account.setFenceToken(7L);account.setLeaseUntilUtc(now().plusDays(2)); + task.setId(1L);task.setDiscoveryScopeId(10L);task.setSourceAccount("synthetic");task.setStatus("RUNNING");task.setTaskRole("ACCOUNT_DISCOVERY");task.setExecutionPurpose("VALIDATION");task.setValidationRootTaskId(1L);task.setValidationBudgetJson(SelfopValidationBudget.freeze(now())); + run.setId(2L);run.setTaskId(1L);run.setSourceAccount("synthetic");run.setStatus("FETCHING");run.setExecutionPurpose("VALIDATION");run.setFenceToken(7L); + when(mapper.lockAccount("synthetic")).thenReturn(account);when(mapper.lockTask(1L)).thenReturn(task);when(mapper.lockRun(2L)).thenReturn(run);when(mapper.saveAccount(any())).thenReturn(1);when(mapper.saveRoot(any())).thenReturn(1); + } + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);} + private SelfopRequestContext context(){return new SelfopRequestContext("synthetic",cfg.getCollectionMode(),1L,"selfop-1","grant",1L,2L);} + private EgressContext reserve(Operation op,int size,String wh,String key){return service.reserve(context(),op,size,wh,key,INTERNAL);} + private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);} + private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);} + private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);} + @Test void offUnknownModeNonProductionBusinessAndUnverifiedHandoffFailBeforeAnyLock(){ + cfg.setCollectionMode("OFF");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_MODE_REJECTED"); + cfg.setCollectionMode("BUSINESS");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED"); + cfg.setCollectionMode("VALIDATION");cfg.setPriorEgressClosed(false);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper); + } + @Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();} + @Test void allFamiliesAndFailuresConsumeOneRootAndAccountAcrossServiceRestart(){ + var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES); + c=reserve(Operation.PRODUCT_INVENTORY,2,"A",null);success(c,Operation.PRODUCT_INVENTORY); + c=reserve(Operation.OUTBOUND_LOCATE,2,null,null);success(c,Operation.OUTBOUND_LOCATE); + var restarted=new SelfopEgressService(mapper,cfg,clock);c=restarted.reserve(context(),Operation.OUTBOUND_DETAIL,0,"A","C/DO",INTERNAL); + restarted.authorize(new AuthorizationRequest(c,Operation.OUTBOUND_DETAIL,PROVIDER)); + restarted.complete(new CompletionRequest(c,Operation.OUTBOUND_DETAIL,PROVIDER,1,"LINGXING_WMS_INVALID_RESPONSE",502,null,false,null)); + assertThat(budget().reservedHttp).isEqualTo(4);assertThat(budget().confirmedHttp).isEqualTo(4);assertThat(rootBudget().pages).isEqualTo(3);assertThat(rootBudget().detailHttp).isEqualTo(1);assertThat(rootBudget().detailKeys).containsExactly("C/DO");assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(task.getNextRetryAtUtc()).isNull(); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,2,null,null)).hasMessage("EGRESS_TASK_NOT_ACTIVE"); + } + @Test void accountSingleFlightHasNoNewCounterWhilePendingAndReplayCannotConsumeTwice(){ + var c=reserve(Operation.WAREHOUSES,2,null,null);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,2,null,null)).hasMessage("ACCOUNT_REQUEST_INFLIGHT");assertThat(budget().reservedHttp).isEqualTo(1); + service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); + assertThatThrownBy(()->service.complete(new CompletionRequest(c,Operation.WAREHOUSES,INTERNAL,1,null,200,200,false,null))).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));assertThat(budget().confirmedHttp).isEqualTo(1); + } + @Test void preparedOperationCannotBeSubstitutedAndProviderHashIsBoundOnce(){ + var c=reserve(Operation.WAREHOUSES,2,null,null);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER))).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + var permit=service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));assertThat(permit.allowedAttempts()).isEqualTo(1);assertThat(permit.expiresAt()).isEqualTo(clock.instant().plusSeconds(30));assertThat(budget().pending.internalRequestHash).isEqualTo(INTERNAL);assertThat(budget().pending.requestHash).isEqualTo(PROVIDER); + } + @Test void pageLimitEndsSameRootAndDoesNotResetAcrossRestart(){ + for(int i=0;i<10;i++){var c=reserve(Operation.OUTBOUND_QUERY,1,null,null);success(c,Operation.OUTBOUND_QUERY);} + assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("VALIDATION_PAGE_LIMIT").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isTrue()); + assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");assertThat(rootBudget().http).isEqualTo(10);verify(mapper).terminateValidationRuns(eq(1L),eq("SAMPLE_DONE"),eq("VALIDATION_PAGE_LIMIT"),any()); + } + @Test void inventoryTwoPageLimitPerWarehouseSharesGlobalBudget(){ + success(reserve(Operation.PRODUCT_INVENTORY,1,"A",null),Operation.PRODUCT_INVENTORY);success(reserve(Operation.PRODUCT_INVENTORY,1,"B",null),Operation.PRODUCT_INVENTORY);success(reserve(Operation.PRODUCT_INVENTORY,1,"A",null),Operation.PRODUCT_INVENTORY); + assertThatThrownBy(()->reserve(Operation.PRODUCT_INVENTORY,1,"A",null)).hasMessage("VALIDATION_INVENTORY_LIMIT");assertThat(rootBudget().inventoryPages).containsEntry("A",2).containsEntry("B",1);assertThat(rootBudget().http).isEqualTo(3); + } + @Test void repeatedDetailUsesHttpLimitEvenForSameIdentity(){for(int i=0;i<20;i++)success(reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO"),Operation.OUTBOUND_DETAIL);assertThatThrownBy(()->reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO")).hasMessage("VALIDATION_DETAIL_HTTP_LIMIT");assertThat(rootBudget().detailKeys).hasSize(1);assertThat(rootBudget().detailHttp).isEqualTo(20);} + @Test void fiveMinuteDeadlineTerminalAndFrozenLimitsCannotIncrease(){clock.advance(300);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("VALIDATION_DEADLINE");assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");var b=rootBudget();b.maxHttp=41;assertThatThrownBy(b::validate).hasMessage("VALIDATION_BUDGET_INVALID");} + @Test void lostResponseRetainsChargeAndConservativelyCoolsAcrossRestart(){reserve(Operation.WAREHOUSES,1,null,null);clock.advance(121);assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT");assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero();assertThat(account.getApiPauseReason()).isEqualTo("REQUEST_OUTCOME_UNKNOWN");assertThat(account.getApiCooldownUntilUtc()).isAfter(now());} + @Test void rateLimitStopsRootAndAllFamiliesRetainCooldown(){var c=reserve(Operation.PRODUCT_INVENTORY,1,"A",null);service.authorize(new AuthorizationRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER));service.complete(new CompletionRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER,1,"LINGXING_WMS_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300)));assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(rootBudget().stopReason).isEqualTo("RATE_LIMITED");assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(300));assertThat(budget().reservedHttp).isEqualTo(1);verify(mapper).terminateValidationRuns(eq(1L),eq("FAILED"),eq("RATE_LIMITED"),any());} + @Test void inheritedDailyBudgetIsNotResetByNewGrantOrDate(){cfg.setTransferredDailyHttpCount(99);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);assertThat(budget().reservedHttp).isEqualTo(100);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_DAILY_BUDGET");cfg.setExclusiveGrantId("grant2");cfg.setTransferredDailyHttpCount(3);assertThatThrownBy(()->service.reserve(new SelfopRequestContext("synthetic","VALIDATION",1L,"selfop-1","grant2",1L,2L),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("ACCOUNT_DAILY_BUDGET");assertThat(budget().reservedHttp).isEqualTo(100);} + @Test void actualStartIntervalUsesAuthorizationTimeAndReturnsQueueDeadline(){var c=reserve(Operation.WAREHOUSES,1,null,null);clock.advance(4);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));clock.advance(1);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT").satisfies(e->assertThat(((EgressRejectedException)e).waitUntilUtc()).isEqualTo(now().plusSeconds(4)));} + @Test void obsoleteExecutionFenceCannotReserveOrConsume(){run.setFenceToken(6L);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_FENCE_LOST");run.setFenceToken(7L);var c=reserve(Operation.WAREHOUSES,1,null,null);account.setFenceToken(8L);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_FENCE_LOST");assertThat(budget().reservedHttp).isEqualTo(1);} + @Test void totalHttpCapCountsEveryOperationAndPreparedReservation(){var b=rootBudget();b.http=40;task.setValidationBudgetJson(SelfopSupport.json(b));assertThatThrownBy(()->reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO")).hasMessage("VALIDATION_HTTP_LIMIT");assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");} + @Test void warehouseRequestsShareAccountBudgetAndRejectScopeMismatch(){task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(100L);task.setWarehouseCode("A");run.setScopeId(100L);run.setWarehouseCode("A");when(mapper.warehouseFence(eq(100L),eq(7L),eq(1L),any())).thenReturn(1);var c=reserve(Operation.PRODUCT_INVENTORY,1,"A",null);success(c,Operation.PRODUCT_INVENTORY);assertThat(budget().reservedHttp).isEqualTo(1);run.setWarehouseCode("B");assertThatThrownBy(()->reserve(Operation.PRODUCT_INVENTORY,1,"A",null)).hasMessage("EGRESS_FENCE_LOST");} + @Test void unknownGrantTransferCooldownIsPreservedAndExpiredGrantCannotIssue(){cfg.setTransferredCooldownUntilUtc(now().plusSeconds(100));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT");cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");} + @Test void persistedDailyCapCannotBeRaisedMiddayToBypassBudget(){cfg.setDailyRequestBudget(1);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);cfg.setDailyRequestBudget(100);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_DAILY_BUDGET");assertThat(budget().dailyLimit).isEqualTo(1);} + @Test void oldAttemptCannotConsumeAfterNextPreparedRequest(){var c=reserve(Operation.WAREHOUSES,1,null,null);success(c,Operation.WAREHOUSES);reserve(Operation.OUTBOUND_QUERY,1,null,null);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_BINDING_INVALID");} + @Test void authorizationsLockAccountBeforeRootAndTask(){var c=reserve(Operation.WAREHOUSES,1,null,null);clearInvocations(mapper);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));var order=inOrder(mapper);order.verify(mapper).lockAccount("synthetic");order.verify(mapper,times(2)).lockTask(1L);order.verify(mapper).lockRun(2L);} + @Test void completedPayloadIsIdempotentAcrossRestartAndBusinessDayWithoutChangingJournal(){ + var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null);service.complete(completion); + String journal=account.getApiBudgetJson(),rootJournal=task.getValidationBudgetJson();String rootStatus=task.getStatus(); + clearInvocations(mapper);var restarted=new SelfopEgressService(mapper,cfg,clock);restarted.complete(completion);clock.advance(86400);restarted.complete(completion); + assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(task.getValidationBudgetJson()).isEqualTo(rootJournal);assertThat(task.getStatus()).isEqualTo(rootStatus); + assertThat(budget().pending.completionHash).isEqualTo(SelfopSupport.hash(completion));verify(mapper,never()).saveAccount(any());verify(mapper,never()).saveRoot(any()); + } + @Test void everyChangedCompletionOutcomeConflictsWithoutOverwritingDurableResult(){ + var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + var completed=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,31001,true,clock.instant().plusSeconds(300));service.complete(completed); + var changed=List.of( + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,0,completed.errorCode(),429,31001,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"OTHER_ERROR",429,31001,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),200,31001,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,429,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,31001,false,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,31001,true,completed.cooldownUntil().plusSeconds(1))); + String journal=account.getApiBudgetJson(),rootJournal=task.getValidationBudgetJson();var cooldown=account.getApiCooldownUntilUtc();String pause=account.getApiPauseReason(); + clearInvocations(mapper); + for(var candidate:changed){assertThatThrownBy(()->service.complete(candidate)).isInstanceOf(EgressRejectedException.class).hasMessage("EGRESS_COMPLETION_CONFLICT");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(task.getValidationBudgetJson()).isEqualTo(rootJournal);assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(account.getApiCooldownUntilUtc()).isEqualTo(cooldown);assertThat(account.getApiPauseReason()).isEqualTo(pause);} + assertThat(budget().pending.completionHash).isEqualTo(SelfopSupport.hash(completed));assertThat(budget().confirmedHttp).isEqualTo(1);verify(mapper,never()).saveAccount(any());verify(mapper,never()).saveRoot(any()); + } + @Test void provider31001And429AreRateLimitsEvenWithHttp200AndNoPauseFlag(){ + for(int sourceCode:new int[]{31001,429}){ + var fixture=new SelfopEgressServiceTest();var c=fixture.reserve(Operation.WAREHOUSES,1,null,null);fixture.service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + fixture.service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,sourceCode,false,null)); + assertThat(fixture.task.getStatus()).isEqualTo("DEAD");assertThat(fixture.rootBudget().stopReason).isEqualTo("RATE_LIMITED");assertThat(fixture.budget().rateLimitFailures).isEqualTo(1);assertThat(fixture.account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN"); + } + } + private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");} + @Test void committedCompletionWithLostReplyStaysPausedAcrossIntegrationRestart(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); + String journal=account.getApiBudgetJson();var until=now().plusSeconds(600);account.setApiCooldownUntilUtc(until); + service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal); + assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);assertThat(budget().confirmedHttp).isEqualTo(1); + clock.advance(601);var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW"); + } + @Test void uncertainUncompletedAttemptCannotDowngradeManualPauseWhenItsLeaseExpires(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE");clock.advance(121); + var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(61); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero(); + } + @Test void uncertainPauseRequiresExactReservedContextAttemptAndOperation(){ + var c=reserve(Operation.WAREHOUSES,1,null,null);String journal=account.getApiBudgetJson();clearInvocations(mapper); + var wrong=new EgressContext(c.sourceAccount(),c.executionPurpose(),c.rootTaskId(),c.sessionId(),"different-attempt",c.exclusiveGrantId()); + assertThatThrownBy(()->service.pauseUncertain(wrong,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE")).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + assertThatThrownBy(()->service.pauseUncertain(c,Operation.OUTBOUND_DETAIL,"INTEGRATION_TRANSPORT_FAILURE")).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + assertThat(account.getApiPauseReason()).isNull();assertThat(account.getApiBudgetJson()).isEqualTo(journal);verify(mapper,never()).saveAccount(any()); + } + @Test void confirmedRatePauseIsNotUpgradedAndStrongerReviewIsPreserved(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300))); + String journal=account.getApiBudgetJson();var until=account.getApiCooldownUntilUtc();clearInvocations(mapper); + service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_HTTP_RATE_LIMITED"); + assertThat(account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);verify(mapper,never()).saveAccount(any()); + account.setApiPauseReason("AUTHORIZATION_REQUIRES_REVIEW");service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE"); + assertThat(account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until); + } + @Test void rateCompletionCommittedButAckLostRequiresReviewDespitePersistedCooldown(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300))); + String journal=account.getApiBudgetJson();var until=account.getApiCooldownUntilUtc(); + service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until); + clock.advance(301);assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);assertThat(budget().rateLimitFailures).isEqualTo(1); + } + @Test void nonRatePauseRequiresReviewAfterCooldownAndRestartWithoutIncreasingRateFailures(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_INVALID_RESPONSE",502,null,true,clock.instant().plusSeconds(60))); + assertThat(budget().rateLimitFailures).isZero();assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(61); + assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");assertThat(budget().reservedHttp).isEqualTo(1);assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW"); + } + @Test void httpAuthorizationFailuresRequireReviewAfterRestart(){ + for(int status:new int[]{401,403}){var fixture=new SelfopEgressServiceTest();fixture.business();var c=fixture.reserve(Operation.WAREHOUSES,1,null,null);fixture.service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));fixture.service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,status,null,false,null));assertThat(fixture.budget().rateLimitFailures).isZero();assertThat(fixture.account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");fixture.clock.advance(61);assertThatThrownBy(()->new SelfopEgressService(fixture.mapper,fixture.cfg,fixture.clock).reserve(fixture.context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");} + } + @Test void businessTrueRateLimitRetainsBoundedBackoffAndMayIssueFreshPermitAfterCooldown(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null)); + assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(60));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT");clock.advance(60); + var restarted=new SelfopEgressService(mapper,cfg,clock);var next=restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL);assertThat(next.attemptId()).isNotEqualTo(c.attemptId());assertThat(restarted.authorize(new AuthorizationRequest(next,Operation.WAREHOUSES,PROVIDER)).allowedAttempts()).isEqualTo(1);restarted.complete(new CompletionRequest(next,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_SOURCE_RATE_LIMITED",200,31001,false,null));assertThat(budget().rateLimitFailures).isEqualTo(2);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(120));assertThat(budget().reservedHttp).isEqualTo(2); + } + private static final class MutableClock extends Clock {private Instant instant=Instant.parse("2026-10-12T00:00:00Z");void advance(long seconds){instant=instant.plusSeconds(seconds);}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;}public Instant instant(){return instant;}} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java new file mode 100644 index 000000000..06a47c8ce --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.selfoperated.analytics.retention; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.*; +import org.mockito.InOrder; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SalesRetentionServiceTest { + final SalesRetentionMapper mapper=mock(SalesRetentionMapper.class); + final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties(); + final SalesRetentionService service=new SalesRetentionService(mapper,config); + SelfopSyncScope scope; SelfopSyncRun old; + @BeforeEach void setup() { + scope=new SelfopSyncScope(); scope.setId(1L);scope.setSourceAccount("demo");scope.setWarehouseCode("A");scope.setDataFamily("OUTBOUND");scope.setPublishedRunId(30L); + old=new SelfopSyncRun();old.setId(10L);old.setScopeId(1L);old.setSourceAccount("demo");old.setWarehouseCode("A");old.setRunRole("WAREHOUSE_SYNC"); + old.setStatus("PUBLISHED");old.setExecutionPurpose("BUSINESS");old.setPublishedAtUtc(SelfopSupport.utcNow().minusDays(9));old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8)); + when(mapper.scope(1L)).thenReturn(scope);when(mapper.run(10L)).thenReturn(old);when(mapper.previous(1L,30L)).thenReturn(20L); + } + @Test void deletionIsOffByDefault() { + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));verifyNoInteractions(mapper); + } + @Test void shortRetentionAndOversizedBatchCannotWeakenGuards() { + config.setRetentionEnabled(true);config.setSalesRetentionDays(1);config.setPersistenceBatchSize(1000); + old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(6));assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + clearInvocations(mapper); + old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8));when(mapper.deleteDaily(10L,500)).thenReturn(500);when(mapper.deleteProfiles(10L,500)).thenReturn(8); + assertEquals(new SalesRetentionService.Deleted(10L,500,8),service.deleteBatch(1L,10L)); + InOrder order=inOrder(mapper);order.verify(mapper).scope(1L);order.verify(mapper).run(10L); + order.verify(mapper).previous(1L,30L);order.verify(mapper).activeReferences("demo","A",10L); + order.verify(mapper).deleteDaily(10L,500);order.verify(mapper).deleteProfiles(10L,500); + // Actual deletion follows scope acquisition, current-pointer/reference checks and fixed bounded predicates. + verify(mapper).deleteDaily(10L,500);verify(mapper).deleteProfiles(10L,500); + } + @Test void currentPreviousActiveInputAndUnpublishedCandidatesAreProtected() { + config.setRetentionEnabled(true); + scope.setPublishedRunId(10L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + scope.setPublishedRunId(30L);when(mapper.previous(1L,30L)).thenReturn(10L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + when(mapper.previous(1L,30L)).thenReturn(20L);when(mapper.activeReferences("demo","A",10L)).thenReturn(1L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + when(mapper.activeReferences("demo","A",10L)).thenReturn(0L);old.setStatus("FAILED");assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + verify(mapper,never()).deleteDaily(anyLong(),anyInt());verify(mapper,never()).deleteProfiles(anyLong(),anyInt()); + } + @Test void pointerAndReferencesRecheckedForEveryBatch() { + config.setRetentionEnabled(true);service.deleteBatch(1L,10L); + when(mapper.activeReferences("demo","A",10L)).thenReturn(1L); + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + verify(mapper,times(2)).scope(1L);verify(mapper,times(2)).activeReferences("demo","A",10L); + verify(mapper,times(1)).deleteDaily(10L,500); + } + @Test void inventoryScopeAndDailyRepresentativeCannotBeCleanedAsSalesCopies() { + config.setRetentionEnabled(true); + scope.setDataFamily("INVENTORY"); + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + scope.setDataFamily("OUTBOUND"); + old.setInventoryDailySampleDate(java.time.LocalDate.of(2026,10,1)); + old.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + verify(mapper,never()).deleteDaily(anyLong(),anyInt()); + verify(mapper,never()).deleteProfiles(anyLong(),anyInt()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java new file mode 100644 index 000000000..51a8b6a9f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class SalesBucketPolicyTest { + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final LocalDate DATE = LocalDate.of(2026, 10, 10); + private static final LocalDate ANCHOR = DATE.plusDays(2); + private static final Instant START = DATE.atStartOfDay(ZONE).toInstant(); + private static final Instant END = DATE.plusDays(1).atStartOfDay(ZONE).toInstant(); + @Test void actualCompleteDayNeedsNoThirtyDayObservation() { + var point = evaluate(DATE, START, END, null, new BigDecimal("2"), true, false, false); + assertEquals("COMPLETE", point.dataStatus()); + assertEquals(new BigDecimal("2"), point.outboundQuantity()); + assertEquals(START, point.provedFrom()); + assertEquals(END, point.provedUntil()); + } + @Test void emptyCompleteDayRequiresExistenceAtMidnightForReliableZero() { + var zero = evaluate(DATE, START, END, START, null, true, false, false); + assertEquals("COMPLETE", zero.dataStatus()); + assertEquals(BigDecimal.ZERO, zero.outboundQuantity()); + var partial = evaluate(DATE, START, END, START.plusSeconds(16 * 3600), null, true, false, false); + assertEquals("PARTIAL", partial.dataStatus()); + assertNull(partial.outboundQuantity()); + assertEquals(BigDecimal.ZERO, partial.observedQuantity()); + } + @Test void firstCollectionMissingMidnightIsPartialEvenWithEarlierExistence() { + var point = evaluate(DATE, START.plusSeconds(3600), END, START.minusSeconds(1), null, true, false, false); + assertEquals("PARTIAL", point.dataStatus()); + assertEquals(BigDecimal.ZERO, point.observedQuantity()); + assertNull(point.outboundQuantity()); + assertEquals(START.plusSeconds(3600), point.provedFrom()); + } + @Test void laterEvidenceDoesNotCreateZeroBeyondCoveredWatermark() { + var point = evaluate(DATE, START, START.plusSeconds(8 * 3600), START.plusSeconds(16 * 3600), null, true, false, false); + assertEquals("UNKNOWN", point.dataStatus()); + assertNull(point.observedQuantity()); + var exactBoundary = evaluate(DATE, START, START.plusSeconds(8 * 3600), START.plusSeconds(8 * 3600), null, true, false, false); + assertEquals("UNKNOWN", exactBoundary.dataStatus()); + } + @Test void actualRowOutsideContinuousCoverageStillShowsObservedFact() { + var point = evaluate(DATE, END, END.plusSeconds(3600), null, new BigDecimal("4"), true, true, true); + assertEquals("PARTIAL", point.dataStatus()); + assertEquals(new BigDecimal("4"), point.observedQuantity()); + assertNull(point.outboundQuantity()); + assertNull(point.provedFrom()); + assertTrue(point.reasonCodes().containsAll(List.of("SALES_REBUILD_REQUIRED", "SALES_STALE"))); + } + @Test void missingEvidenceMissingCoverageAndUnknownUnitRemainUnknown() { + assertEquals("UNKNOWN", evaluate(DATE, START, END, null, null, true, false, false).dataStatus()); + assertEquals("UNKNOWN", evaluate(DATE, null, null, START, null, true, false, false).dataStatus()); + var unit = evaluate(DATE, START, END, START, BigDecimal.TEN, false, false, false); + assertNull(unit.observedQuantity()); + assertTrue(unit.reasonCodes().contains("UNIT_UNKNOWN_OR_CONFLICT")); + var unavailableVersion = SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(DATE, ANCHOR, ZONE, + DATE, ANCHOR, START, END, START, BigDecimal.TEN, 1L, true, false, false, false)); + assertEquals("UNKNOWN", unavailableVersion.dataStatus()); + assertNull(unavailableVersion.observedQuantity()); + } + @Test void todayNeverCompleteAndThreeDayAggregatePreservesCollectedFive() { + var first = evaluate(DATE, START, END, START, new BigDecimal("2"), true, false, false); + var second = evaluate(DATE.plusDays(1), START, END.plusSeconds(86400), START, null, true, false, false); + var today = evaluate(ANCHOR, START, END.plusSeconds(2 * 86400), START, new BigDecimal("3"), true, false, false); + assertEquals("PARTIAL", today.dataStatus()); + assertTrue(today.reasonCodes().contains("TODAY_INCOMPLETE")); + var summary = SalesBucketPolicy.aggregate(List.of(first, second, today)); + assertNull(summary.outboundQuantity()); + assertEquals(new BigDecimal("5"), summary.observedQuantity()); + assertEquals(2, summary.completeDays()); + assertEquals(1, summary.partialDays()); + assertEquals("PARTIAL", summary.dataStatus()); + } + @Test void fullyUnknownAndEmptyRangesDoNotSumToZeroWhileCompleteZerosDo() { + var unknown = evaluate(DATE, null, null, null, null, true, false, false); + var summary = SalesBucketPolicy.aggregate(List.of(unknown)); + assertEquals("UNKNOWN", summary.dataStatus()); + assertNull(summary.observedQuantity()); + assertNull(SalesBucketPolicy.aggregate(List.of()).observedQuantity()); + var zero = evaluate(DATE, START, END, START, null, true, false, false); + assertEquals(BigDecimal.ZERO, SalesBucketPolicy.aggregate(List.of(zero)).outboundQuantity()); + } + private SalesBucketPolicy.Point evaluate(LocalDate date, Instant from, Instant until, Instant evidence, + BigDecimal quantity, boolean unit, boolean rebuild, boolean stale) { + return SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(date, ANCHOR, ZONE, DATE, + ANCHOR.plusDays(1), from, until, evidence, quantity, quantity == null ? null : 1L, + unit, true, rebuild, stale)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java new file mode 100644 index 000000000..86f5c2b29 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import static org.junit.jupiter.api.Assertions.*; + +class SalesRangePolicyTest { + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final Instant AS_OF = Instant.parse("2026-10-12T02:00:00Z"); + @Test void fiveNaturalPresetsKeepDailyPoints() { + String[] presets = {"TODAY", "LAST_3_DAYS", "THIS_WEEK", "LAST_15_DAYS", "THIS_MONTH"}; + String[] starts = {"2026-10-12", "2026-10-10", "2026-10-12", "2026-09-28", "2026-10-01"}; + int[] counts = {1, 3, 1, 15, 12}; + for (int i = 0; i < presets.length; i++) { + var range = SalesRangePolicy.resolve(presets[i], null, null, "DAY", AS_OF, ZONE, 366); + assertEquals(LocalDate.parse(starts[i]), range.startDate()); + assertEquals(counts[i], range.dayCount()); + assertEquals(LocalDate.of(2026, 10, 13), range.endDateExclusive()); + assertEquals(Instant.parse("2026-10-12T16:00:00Z"), range.endAtExclusive()); + } + } + @Test void weekMayCrossYearAndAnchorUsesExplicitZone() { + var range = SalesRangePolicy.resolve("THIS_WEEK", null, null, "DAY", + Instant.parse("2025-12-31T17:00:00Z"), ZONE, 366); + assertEquals(LocalDate.of(2026, 1, 1), range.anchorDate()); + assertEquals(LocalDate.of(2025, 12, 29), range.startDate()); + assertEquals(4, range.dayCount()); + } + @Test void customIncludesLeapDayAndHasHardMaximum() { + var asOf = Instant.parse("2024-12-31T04:00:00Z"); + var range = SalesRangePolicy.resolve("CUSTOM", LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 12, 31), "DAY", asOf, ZONE, 366); + assertEquals(366, range.dayCount()); + assertThrows(IllegalArgumentException.class, () -> SalesRangePolicy.resolve("CUSTOM", + LocalDate.of(2023, 12, 31), LocalDate.of(2024, 12, 31), "DAY", asOf, ZONE, 366)); + } + @Test void explicitTestZoneUsesCalendarDaysAcrossDaylightSavingChange() { + var range = SalesRangePolicy.resolve("TODAY", null, null, "DAY", + Instant.parse("2026-03-08T12:00:00Z"), ZoneId.of("America/New_York"), 366); + assertEquals(1, range.dayCount()); + assertEquals(23 * 3600L, java.time.Duration.between(range.startAt(), range.endAtExclusive()).getSeconds()); + } + @Test void conflictsUnknownEnumsFutureReversedAndMissingDatesAreRejected() { + assertThrows(IllegalArgumentException.class, () -> resolve("WEEK", null, null, "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("TODAY", null, null, "WEEK")); + assertThrows(IllegalArgumentException.class, () -> resolve("TODAY", LocalDate.of(2026, 10, 1), null, "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", null, null, "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", LocalDate.of(2026, 10, 12), LocalDate.of(2026, 10, 13), "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", LocalDate.of(2026, 10, 12), LocalDate.of(2026, 10, 11), "DAY")); + assertThrows(IllegalArgumentException.class, () -> SalesRangePolicy.resolve("LAST_3_DAYS", null, null, "DAY", AS_OF, ZONE, 2)); + } + private SalesRangePolicy.Range resolve(String preset, LocalDate start, LocalDate end, String bucket) { + return SalesRangePolicy.resolve(preset, start, end, bucket, AS_OF, ZONE, 366); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java new file mode 100644 index 000000000..58877357d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.time.LocalDateTime; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.read; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SelfopCommandServiceTest { + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopCommandService service = new SelfopCommandService(taskService, scopes, accounts, config, tasks); + + private void configure() { + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSourceAccount("synthetic-account"); + config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse()); + } + + @Test void retryCannotResumeTaskBelongingToAnotherWarehouse() { + configure(); + var original = original("synthetic-account", "WH-B"); + when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; }); + assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH"); + verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString()); + verify(taskService, never()).create(any(SelfopSyncTask.class)); + assertThat(original.getStatus()).isEqualTo("DEAD"); + } + + @Test void retryCannotResumeTaskBelongingToAnotherSourceAccount() { + configure(); + var original = original("different-source-account", "WH-A"); + when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; }); + assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH"); + verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString()); + verify(taskService, never()).create(any(SelfopSyncTask.class)); + assertThat(original.getStatus()).isEqualTo("DEAD"); + } + + @Test void unknownSourceTimezoneAllowsRawBackfillTaskWithoutClaimingUtcCoverage() { + configure(); + var scope = new SelfopSyncScope(); scope.setId(10L); scope.setScopeHash("synthetic-scope-hash"); + var account = new SelfopOutboundDiscoveryScope(); account.setId(20L); + when(scopes.selectOne(any())).thenReturn(scope); + when(accounts.selectOne(any())).thenReturn(account); + when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(call -> call.getArgument(0)); + var from = LocalDateTime.of(2026, 10, 11, 8, 0); + var until = from.plusHours(12); + var command = new SelfopCommandService.Command("raw-slice", "WH-A", "BACKFILL", null, from, until, null, null, "offline sample"); + var task = service.request(command, "42"); + assertThat(config.getSourceQueryTimezone()).isNull(); + assertThat(config.isExecutionEnabled()).isTrue(); + assertThat(task.getQueryFromUtc()).isNull(); + assertThat(task.getQueryUntilUtc()).isNull(); + assertThat(task.getTaskRole()).isEqualTo("ACCOUNT_DISCOVERY"); + assertThat(task.getDiscoveryScopeId()).isEqualTo(20L); + var query = read(task.getQueryScopeJson(), SelfopDiscoveryService.Query.class); + assertThat(query.startTime()).isEqualTo(from.toString()); + assertThat(query.endTime()).isEqualTo(until.toString()); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(taskService).create(captured.capture()); + assertThat(captured.getValue()).isSameAs(task); + verifyNoInteractions(tasks); + } + + private SelfopSyncTask original(String sourceAccount, String warehouse) { + var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(100L); task.setSourceAccount(sourceAccount); + task.setWarehouseCode(warehouse); task.setTaskRole("WAREHOUSE_SYNC"); task.setStatus("DEAD"); + return task; + } + @Test void offModeAndNonProductionBusinessDoNotAcceptNewCollectionTask() { + configure();config.setCollectionMode("OFF"); + assertThat(config.isExecutionEnabled()).isFalse(); + var c=new SelfopCommandService.Command("disabled","WH-A","INVENTORY",null,null,null,null,null,"offline test"); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_PURPOSE_INVALID"); + config.setCollectionMode("BUSINESS");config.setEnvironment("test"); + assertThat(config.isExecutionEnabled()).isFalse(); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_BUSINESS_ENVIRONMENT_REJECTED"); + verify(taskService,never()).create(any(SelfopSyncTask.class)); + } + @Test void validationModeRejectsFormalInventoryEntry() { + configure();config.setCollectionMode("VALIDATION"); + var c=new SelfopCommandService.Command("wrong-purpose","WH-A","INVENTORY",null,null,null,null,null,"offline test"); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_VALIDATION_ENTRY_REQUIRED"); + verify(taskService,never()).create(any(SelfopSyncTask.class)); + } + private SelfopCommandService.Command retry() { + return new SelfopCommandService.Command("retry-key", "WH-A", "RETRY", "LXCOL-20261012-00000001", null, null, null, null, "offline retry"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java new file mode 100644 index 000000000..fd611b675 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java @@ -0,0 +1,213 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SelfopDiscoveryServiceTest { + private final LingxingAnalyticsClient client = mock(LingxingAnalyticsClient.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopOutboundOrderMapper orders = mock(SelfopOutboundOrderMapper.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(30L, "account-worker", 4L, true); + private final Map stored = new LinkedHashMap<>(); + private final Map children = new LinkedHashMap<>(); + private final LocalDateTime from = LocalDateTime.of(2026, 10, 11, 0, 0); + private final LocalDateTime until = from.plusDays(1); + private SelfopDiscoveryService service; + private SelfopSyncTask task; + private SelfopSyncRun run; + private SelfopSyncScope scope; + private SelfopOutboundDiscoveryScope account; + + @BeforeEach void prepare() { + doAnswer(inv -> { Consumer cb = inv.getArgument(0); cb.accept(mock(TransactionStatus.class)); return null; }) + .when(tx).executeWithoutResult(any()); + account = new SelfopOutboundDiscoveryScope(); account.setId(30L); account.setRequestBudget(10); account.setTimeBudgetSeconds(60); + when(accounts.selectById(30L)).thenReturn(account); + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setSourceAccount("demo"); task.setDiscoveryScopeId(30L); + task.setDiscoverySequence(17L); task.setObservationBaseline(90L); task.setTaskType("SCHEDULED"); task.setScopeHash("account-hash"); + task.setQueryFromUtc(from); task.setQueryUntilUtc(until); task.setStatus("RUNNING"); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from.toString(), until.toString(), null, null, null))); + run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(20L); run.setStatus("FETCHING"); + scope = new SelfopSyncScope(); scope.setId(10L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); + scope.setDataFamily("OUTBOUND"); scope.setScopeHash("warehouse-hash"); scope.setScopeJson("{}"); scope.setSourceTimezone("UTC"); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setSourceQueryTimezone("UTC"); config.setQuantityUnit("PCS"); + when(scopes.selectList(any())).thenReturn(List.of(scope)); + when(leases.nextObservation(lease)).thenReturn(101L, 102L, 103L); + when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(inv -> { + SelfopSyncTask child = inv.getArgument(0); + child.setId(100L + children.size()); + return children.computeIfAbsent(child.getRequestKey(), ignored -> child); + }); + doAnswer(inv -> { SelfopSyncStage stage = inv.getArgument(0); stored.put(stage.getId(), stage); return 1; }) + .when(stages).insert(any(SelfopSyncStage.class)); + doAnswer(inv -> { SelfopSyncStage stage = inv.getArgument(0); stored.put(stage.getId(), stage); return 1; }) + .when(stages).updateById(any(SelfopSyncStage.class)); + when(stages.selectById(anyLong())).thenAnswer(inv -> stored.get((Long) inv.getArgument(0))); + when(stages.selectOne(any())).thenAnswer(inv -> { + QueryWrapper query = inv.getArgument(0); String sql = query.getSqlSegment(); + if (sql.contains("observation_sequence")) return null; + var values = query.getParamNameValuePairs().values(); + return stored.values().stream().filter(s -> values.contains(s.getStageBatchId()) && values.contains(s.getObjectKeyHash())).findFirst().orElse(null); + }); + when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString())).thenAnswer(inv -> detail(inv.getArgument(3), "2026-10-11 12:00:00")); + service = new SelfopDiscoveryService(client, tasks, runs, stages, orders, scopes, accounts, taskService, leases, config, tx); + } + private LingxingAnalyticsClient.Summary summary(String no, String time) { + return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time); + } + private SelfopPayloads.Order detail(String no, String time) { + return new SelfopPayloads.Order("demo", "customer", "A", no, null, 100, null, time, + List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS"))); + } + private LingxingAnalyticsClient.Page page(int current, int size, long total, int pages, + LingxingAnalyticsClient.Summary... rows) { return new LingxingAnalyticsClient.Page<>(List.of(rows), current, size, total, pages); } + private void directoryTwoOrders() { + when(client.discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2))).thenReturn(page(1, 2, 2, 1, + summary("one", "2026-10-11 12:00:00"), summary("two", "2026-10-11 12:00:00"))); + } + + @Test void paginatedLocateYieldsDurableCursorThenResumesWithoutFiniteFailure() { + config.setPageSize(1); account.setRequestBudget(1); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null, null, "target", "customer", null))); + when(client.locate(any(SelfopRequestContext.class), eq("target"), eq(1), eq(1))).thenReturn(page(1, 1, 2, 2, summary("target", "2026-10-11 12:00:00"))); + when(client.locate(any(SelfopRequestContext.class), eq("target"), eq(2), eq(1))).thenReturn(page(2, 1, 2, 2, summary("other", "2026-10-11 12:00:00"))); + service.collect(task, run, lease); + var saved = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(2, saved.getNextPage()); assertEquals(1, saved.getObjects().size()); assertFalse(saved.isDirectoryComplete()); + verify(taskService).yield(1L, 20L, lease); verify(client, never()).detail(any(SelfopRequestContext.class), anyString(), anyString(), anyString()); + account.setRequestBudget(10); service.collect(task, run, lease); + verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("target"), eq(1), eq(1)); verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("target"), eq(2), eq(1)); + assertEquals("ROUTED", run.getStatus()); + verify(taskService, never()).fail(anyLong(), anyLong(), anyString(), anyBoolean(), any()); + } + @Test void stableDirectoryScansYieldAndResumeBeforeDetailsWithoutRepeatingFirstScan() { + directoryTwoOrders(); account.setRequestBudget(1); + service.collect(task, run, lease); + var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(2, cursor.getScan()); assertEquals(1, cursor.getNextPage()); assertEquals(2, cursor.getFirstScan().size()); + assertEquals(2, stored.size()); assertTrue(children.isEmpty()); + account.setRequestBudget(10); service.collect(task, run, lease); + verify(client, times(2)).discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2)); + verify(client, times(2)).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString()); + assertEquals(2, stored.size()); assertTrue(stored.values().stream().allMatch(s -> "VALID".equals(s.getValidationStatus()))); + assertEquals("ROUTED", run.getStatus()); assertEquals(2L, run.getUniqueObjectCount()); + } + @Test void eachActualDetailGetsIndependentObservationNotTaskSequence() { + directoryTwoOrders(); service.collect(task, run, lease); + assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList())); + assertTrue(stored.values().stream().allMatch(s -> s.getStageBatchId().equals(1L) && !s.getObservationSequence().equals(task.getDiscoverySequence()))); + verify(leases, times(2)).nextObservation(lease); + assertEquals(17L, children.values().iterator().next().getDiscoverySequence()); + var contexts=org.mockito.ArgumentCaptor.forClass(SelfopRequestContext.class); + verify(client,times(2)).detail(contexts.capture(),eq("A"),eq("customer"),anyString()); + assertTrue(contexts.getAllValues().stream().allMatch(c->"demo".equals(c.sourceAccount())&&"BUSINESS".equals(c.executionPurpose()) + &&Objects.equals(c.taskId(),1L)&&Objects.equals(c.runId(),20L))); + } + @Test void unknownWarehouseSourceTimezoneKeepsChildWaitingAndNoSalesCoverage() { + directoryTwoOrders(); scope.setSourceTimezone(null); service.collect(task, run, lease); + var child = children.values().iterator().next(); assertEquals("WAITING_SOURCE", child.getStatus()); + assertEquals(2L, read(child.getRoutingManifestJson(), SelfopRoutingManifest.class).getTotal()); + assertNull(scope.getDiscoveryFromUtc()); assertNull(scope.getDiscoveryUntilUtc()); + assertNull(scope.getPublishedRunId()); verify(taskService, never()).begin(anyLong(), any()); + } + @Test void knownExclusiveEndpointRemainsAccountEvidenceButLeavesWarehouseSlice() { + when(client.discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, summary("end", "2026-10-12 00:00:00"))); + when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("end"))).thenReturn(detail("end", "2026-10-12 00:00:00")); + service.collect(task, run, lease); + assertEquals(1L, run.getSourceTotalCount()); assertEquals(1L, run.getUniqueObjectCount()); + var child = children.values().iterator().next(); var routed = read(child.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(0L, routed.getTotal()); assertTrue(routed.getObjects().isEmpty()); assertEquals("READY", child.getStatus()); + assertEquals(1, stored.size()); assertEquals("VALID", stored.values().iterator().next().getValidationStatus()); + } + + @Test void relocationBudgetYieldPersistsRefreshAndCurrentWarehouseThenResumesWithoutLocatingAgain() { + String key = completedParent("moved"); account.setRequestBudget(2); + when(tasks.selectById(anyLong())).thenAnswer(inv -> { + Long id = inv.getArgument(0); + return id.equals(task.getId()) ? task : children.values().stream().filter(t -> id.equals(t.getId())).findFirst().orElse(null); + }); + Map refreshRuns = new HashMap<>(); + when(taskService.begin(anyLong(), eq(lease))).thenAnswer(inv -> { + Long id = inv.getArgument(0); + return refreshRuns.computeIfAbsent(id, ignored -> { + var attempt = new SelfopSyncRun(); attempt.setExecutionPurpose("BUSINESS"); attempt.setSourceAccount("demo"); attempt.setId(1000L + id); attempt.setTaskId(id); + attempt.setAttemptNo(1); attempt.setFenceToken(lease.fence()); attempt.setStatus("FETCHING"); return attempt; + }); + }); + doAnswer(inv -> { + Long id = inv.getArgument(0); SelfopSyncTask yielded = id.equals(task.getId()) ? task + : children.values().stream().filter(t -> id.equals(t.getId())).findFirst().orElseThrow(); + yielded.setStatus("RETRY_WAIT"); return null; + }).when(taskService).yield(anyLong(), anyLong(), eq(lease)); + when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved"))) + .thenThrow(new AnalyticsClientException("LINGXING_WMS_SOURCE_REJECTED", false)); + var moved = new LingxingAnalyticsClient.Summary("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00"); + when(client.locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, moved)); + when(client.detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"))).thenReturn(new SelfopPayloads.Order("demo", "customer", "B", "moved", null, + 100, null, "2026-10-11 12:00:00", List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS")))); + + service.collect(task, run, lease); + var parentRef = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key); + assertEquals("B", parentRef.getWarehouseCode()); assertNotNull(parentRef.getRefreshTaskId()); assertNull(parentRef.getEffectiveStageId()); + var refresh = children.values().stream().filter(t -> parentRef.getRefreshTaskId().equals(t.getId())).findFirst().orElseThrow(); + assertEquals("RECHECK", refresh.getTaskType()); assertEquals("RETRY_WAIT", refresh.getStatus()); + assertTrue(read(refresh.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete()); + verify(client, never()).detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved")); + + account.setRequestBudget(10); service.collect(task, run, lease); + var finalRef = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key); + assertEquals(parentRef.getRefreshTaskId(), finalRef.getRefreshTaskId()); assertNotNull(finalRef.getEffectiveStageId()); + assertEquals("B", finalRef.getWarehouseCode()); assertEquals("SUCCEEDED", refresh.getStatus()); assertEquals("ROUTED", run.getStatus()); + var evidence = stored.get(finalRef.getEffectiveStageId()); assertEquals(102L, evidence.getObservationSequence()); + assertEquals(refresh.getId(), evidence.getStageBatchId()); + verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2)); + verify(client, times(1)).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved")); + verify(client, times(1)).detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved")); + } + + @Test void authenticationAndSignatureFailuresNeverFallbackToOrderRelocation() { + completedParent("protected"); + for (String code : List.of("LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED")) { + doThrow(new AnalyticsClientException(code, false)).when(client).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("protected")); + assertEquals(code, assertThrows(AnalyticsClientException.class, () -> service.collect(task, run, lease)).code()); + } + verify(client, never()).locate(any(SelfopRequestContext.class), anyString(), anyInt(), anyInt()); + assertTrue(children.values().stream().noneMatch(t -> "RECHECK".equals(t.getTaskType()))); + } + + private String completedParent(String deliveryNo) { + var manifest = new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); manifest.setPages(1); manifest.setTotal(1L); + var entry = new SelfopRoutingManifest.Entry(); entry.setCustomerCode("customer"); entry.setDeliveryNo(deliveryNo); entry.setWarehouseCode("A"); + String key = hash(List.of("demo", "customer", deliveryNo)); manifest.getObjects().put(key, entry); + task.setRoutingManifestJson(json(manifest)); return key; + } + @Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() { + task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage()); + assertNull(account.getCatalogUntilUtc());assertNull(scope.getDiscoveryUntilUtc());assertTrue(children.isEmpty()); + verifyNoInteractions(client); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java new file mode 100644 index 000000000..9a0914a9c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.outbound.*; +import com.baiyue.lastleg.selfoperated.analytics.inventory.InventorySnapshotService; +import com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.mockito.Mockito.*; + +class SelfopExecutionServiceTest { + @Test void unexpectedValidationFailureTerminatesSessionInsteadOfSchedulingRetry() { + executeValidationFailure(new IllegalStateException("VALIDATION_STRUCTURE_REJECTED"),"VALIDATION_STRUCTURE_REJECTED"); + } + @Test void escapingValidationWaitCannotUseBusinessYieldPath() { + executeValidationFailure(new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,LocalDateTime.now().plusMinutes(1)), + "ACCOUNT_INTERVAL_WAIT"); + } + private void executeValidationFailure(RuntimeException failure,String code) { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("VALIDATION"); + var taskService=mock(SelfopTaskService.class);var leases=mock(SelfopLeaseService.class); + var validation=mock(SelfopValidationRunner.class);var tasks=mock(SelfopSyncTaskMapper.class); + var discovery=mock(SelfopDiscoveryService.class);var stock=mock(SelfopInventoryCollector.class); + var snapshots=mock(InventorySnapshotService.class);var facts=mock(OutboundFactService.class); + var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class); + var recovery=mock(SelfopReferenceRecovery.class); + var task=new SelfopSyncTask();task.setId(1L);task.setTaskRole("ACCOUNT_DISCOVERY"); + task.setDiscoveryScopeId(3L);task.setExecutionPurpose("VALIDATION");task.setStatus("READY");task.setValidationRootTaskId(1L); + var run=new SelfopSyncRun();run.setId(2L);run.setTaskId(1L);run.setExecutionPurpose("VALIDATION"); + var lease=new SelfopLeaseService.Lease(3L,"owner",8L,true); + when(tasks.selectById(1L)).thenReturn(task);when(leases.acquireDiscovery(eq(3L),anyString())).thenReturn(lease); + when(taskService.begin(1L,lease)).thenReturn(run);doThrow(failure).when(validation).run(task,run,lease); + var service=new SelfopExecutionService(config,taskService,leases,discovery,stock,snapshots,facts,candidates,publisher, + mock(SelfopSyncScopeMapper.class),tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class), + recovery,validation,mock(SelfopSyncRunMapper.class)); + service.execute(1L); + verify(validation).fail(task,run,lease,code);verify(leases).release(lease); + verify(taskService,never()).fail(anyLong(),anyLong(),anyString(),anyBoolean(),any()); + verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any()); + verifyNoInteractions(discovery,stock,snapshots,facts,candidates,publisher,recovery); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java new file mode 100644 index 000000000..6b54643e8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java @@ -0,0 +1,150 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Fully offline source pages; the transaction callback fake verifies orchestration, not DB rollback. */ +class SelfopInventoryCollectorTest { + private final LingxingAnalyticsClient client = mock(LingxingAnalyticsClient.class); + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(1L, "owner", 4L, false); + private SelfopInventoryCollector collector; + private SelfopSyncTask task; + private SelfopSyncRun run; + + @BeforeEach void prepare() { + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); return null; + }).when(tx).executeWithoutResult(any()); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); + collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx); + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(20L); task.setSourceAccount("demo"); task.setWarehouseCode("A"); + run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(10L); run.setTaskId(20L); run.setStatus("FETCHING"); + run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setFetchStartedAtUtc(utcNow()); + } + private SelfopPayloads.Stock stock(String sku, BigDecimal available) { + return new SelfopPayloads.Stock("demo", "customer", "A", sku, 0, "Name", + BigDecimal.TEN, available, BigDecimal.ZERO, BigDecimal.ZERO, null); + } + private LingxingAnalyticsClient.Page page(int current, long total, int pages, SelfopPayloads.Stock... rows) { + return new LingxingAnalyticsClient.Page<>(List.of(rows), current, 2, total, pages); + } + private void stableThreeRows() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO))); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenReturn(page(2, 3, 2, stock("c", null))); + } + + @Test void twoIdenticalScansValidateOneAttemptAndOnlyFirstScanWritesEvidence() { + stableThreeRows(); + assertTrue(collector.collect(task, run, lease)); + assertEquals("VALIDATED", run.getStatus()); assertEquals("COMPLETE", run.getCoverageStatus()); + assertEquals(3L, run.getSourceTotalCount()); assertEquals(3L, run.getUniqueObjectCount()); assertEquals(2, run.getPageCount()); + verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2)); + var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); + verify(stages, times(3)).insert(capture.capture()); + assertTrue(capture.getAllValues().stream().allMatch(s -> s.getRunId() == 10L && s.getStageBatchId() == 10L && s.getLastFetchRunId() == 10L)); + assertNotNull(run.getFetchFinishedAtUtc()); + var contexts=ArgumentCaptor.forClass(SelfopRequestContext.class); + verify(client,times(4)).inventory(contexts.capture(),eq("A"),anyInt(),eq(2)); + assertTrue(contexts.getAllValues().stream().allMatch(c->"demo".equals(c.sourceAccount())&&"BUSINESS".equals(c.executionPurpose()) + &&Objects.equals(c.taskId(),20L)&&Objects.equals(c.runId(),10L)&&Objects.equals(c.rootTaskId(),20L))); + } + @Test void duplicateKeyRejectsReportedTotalEvenIfNumberOfReturnedRowsMatches() { + // Scaled version of the observed 5741 rows / 5740 unique dimensions incident. + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO))); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenReturn(page(2, 3, 2, stock("a", BigDecimal.ONE))); + var error = assertThrows(IllegalStateException.class, () -> collector.collect(task, run, lease)); + assertEquals("SELFOP_STOCK_DUPLICATE", error.getMessage()); + assertIncompleteAttemptWithPersistedProgress(); + } + @Test void sourcePageFailureCannotMarkAttemptComplete() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO))); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenThrow(new IllegalStateException("SOURCE_TIMEOUT")); + assertThrows(IllegalStateException.class, () -> collector.collect(task, run, lease)); + assertFalse(read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete()); + assertIncompleteAttemptWithPersistedProgress(); + } + @Test void leakedWarehouseIsRejectedWithoutCompletingRun() { + var leaked = new SelfopPayloads.Stock("demo", "customer", "B", "sku", 0, null, + BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 1, 1, leaked)); + assertEquals("SELFOP_STOCK_SCOPE_LEAK", assertThrows(IllegalStateException.class, + () -> collector.collect(task, run, lease)).getMessage()); + verify(stages, never()).insert(any(SelfopSyncStage.class)); verify(runs, never()).updateById(any(SelfopSyncRun.class)); + } + @Test void unknownAndExplicitZeroRemainDifferentInDurablePayloads() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 2, 1, stock("zero", BigDecimal.ZERO), stock("unknown", null))); + assertTrue(collector.collect(task, run, lease)); + var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages, times(2)).insert(capture.capture()); + var first = read(capture.getAllValues().get(0).getPayloadJson(), SelfopPayloads.Stock.class); + var second = read(capture.getAllValues().get(1).getPayloadJson(), SelfopPayloads.Stock.class); + assertEquals(BigDecimal.ZERO, first.availableQuantity()); assertNull(second.availableQuantity()); + } + @Test void budgetYieldPersistsNextPageAndResumeUsesOriginalAttempt() { + stableThreeRows(); config.setRequestBudget(1); + assertFalse(collector.collect(task, run, lease)); + var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(1, cursor.getScan()); assertEquals(2, cursor.getNextPage()); assertEquals(2, cursor.getFirstScan().size()); + verify(taskService).yield(20L, 10L, lease); + // Simulate restart by constructing a fresh collector and reading only the persisted task cursor. + var restarted = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx); + config.setRequestBudget(10); + assertTrue(restarted.collect(task, run, lease)); + var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages, times(3)).insert(capture.capture()); + assertTrue(capture.getAllValues().stream().allMatch(s -> Objects.equals(s.getStageBatchId(), 10L))); + verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2)); + assertEquals("COMPLETE", run.getCoverageStatus()); + } + @Test void secondScanCanYieldAndResumeWithoutDuplicatingFirstScanStages() { + stableThreeRows(); config.setRequestBudget(3); + assertFalse(collector.collect(task, run, lease)); + var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(2, cursor.getScan()); assertEquals(2, cursor.getNextPage()); assertEquals(2, cursor.getObjects().size()); + config.setRequestBudget(10); assertTrue(collector.collect(task, run, lease)); + verify(stages, times(3)).insert(any(SelfopSyncStage.class)); + verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2)); + } + @Test void secondScanChangedQuantityCannotPassByKeepingSameIdentitySet() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 1, 1, stock("a", BigDecimal.ONE)), + page(1, 1, 1, stock("a", BigDecimal.TEN))); + assertEquals("SELFOP_STOCK_SCAN_UNSTABLE", assertThrows(IllegalStateException.class, + () -> collector.collect(task, run, lease)).getMessage()); + assertIncompleteAttemptWithPersistedProgress(); + } + @Test void validationPurposeCannotReachFormalCollector() { + task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage()); + verifyNoInteractions(client,stages,tasks,runs); + } + private void assertIncompleteAttemptWithPersistedProgress() { + assertEquals("FETCHING",run.getStatus()); + assertNull(run.getFetchFinishedAtUtc()); + assertNull(run.getCoverageStatus()); + assertNull(run.getSourceTotalCount()); + assertNull(run.getUniqueObjectCount()); + assertNotNull(run.getHeartbeatAtUtc()); + verify(runs,times(1)).updateById(argThat((SelfopSyncRun saved)-> + "FETCHING".equals(saved.getStatus()) && saved.getFetchFinishedAtUtc()==null + && saved.getCoverageStatus()==null && saved.getHeartbeatAtUtc()!=null)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java new file mode 100644 index 000000000..e5ecda812 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SelfopInventoryRecoveryTest { + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(10L, "worker", 7L, false); + private final SelfopTaskService service = new SelfopTaskService(scopes, accounts, tasks, runs, locks, config, leases); + private SelfopSyncTask task; + private SelfopSyncRun original; + private SelfopSyncScope inventory; + + @BeforeEach void prepare() { + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setScopeId(10L); task.setSourceAccount("demo"); + task.setWarehouseCode("A"); task.setTaskRole("WAREHOUSE_SYNC"); task.setStatus("RUNNING"); + task.setApplyState("NOT_APPLICABLE"); task.setAttemptCount(1); task.setScopeHash("inventory-hash"); task.setQueryScopeJson("{}"); + var manifest = new SelfopRoutingManifest(); manifest.setNextPage(2); manifest.setTotal(3L); manifest.setPages(2); + manifest.getFirstScan().put("old-key", "old-value"); task.setRoutingManifestJson(json(manifest)); + original = new SelfopSyncRun(); original.setExecutionPurpose("BUSINESS"); original.setId(20L); original.setTaskId(1L); original.setScopeId(10L); + original.setSourceAccount("demo"); original.setWarehouseCode("A"); original.setAttemptNo(1); + original.setFenceToken(6L); original.setStatus("FETCHING"); original.setBasePublishedRunId(11L); + original.setReferenceSalesRunId(12L); original.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); + original.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 0, 0)); + inventory = new SelfopSyncScope(); inventory.setId(10L); inventory.setDataFamily("INVENTORY"); + inventory.setSourceAccount("demo"); inventory.setWarehouseCode("A"); inventory.setScopeHash("inventory-hash"); + inventory.setScopeJson("{\"quantityUnit\":\"PCS\"}"); inventory.setPublishedRunId(90L); + inventory.setBusinessTimezone("UTC"); inventory.setStaleAfterSeconds(93600); + var sales = new SelfopSyncScope(); sales.setId(30L); sales.setDataFamily("OUTBOUND"); sales.setPublishedRunId(80L); + when(locks.lockTask(1L)).thenReturn(task); when(locks.lockRun(20L)).thenReturn(original); + when(locks.lockScope(30L)).thenReturn(sales); when(scopes.selectById(10L)).thenReturn(inventory); when(scopes.selectOne(any())).thenReturn(sales); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); config.setPageSize(2); + } + + @Test void budgetYieldResumesSameAttemptWithFixedReferencesAndOriginalObservationStart() { + String cursor = task.getRoutingManifestJson(); String snapshot = original.getScopeSnapshotJson(); + var started = original.getFetchStartedAtUtc(); + original.setFenceToken(lease.fence()); + service.yield(1L, 20L, lease); + assertEquals("YIELDED", original.getStatus()); assertEquals("RETRY_WAIT", task.getStatus()); + assertNotNull(original.getFinishedAtUtc()); + when(runs.selectOne(any())).thenReturn(null, original); + var resumed = service.begin(1L, lease); + assertSame(original, resumed); assertEquals(20L, resumed.getId()); assertEquals(1, resumed.getAttemptNo()); + assertEquals(11L, resumed.getBasePublishedRunId()); assertEquals(12L, resumed.getReferenceSalesRunId()); + assertEquals(snapshot, resumed.getScopeSnapshotJson()); assertEquals(started, resumed.getFetchStartedAtUtc()); + assertEquals(cursor, task.getRoutingManifestJson()); assertEquals(1, task.getAttemptCount()); + assertEquals(7L, resumed.getFenceToken()); assertEquals("FETCHING", resumed.getStatus()); assertNull(resumed.getFinishedAtUtc()); + verify(runs, never()).insert(any(SelfopSyncRun.class)); + } + + @Test void genuineFailureClearsCursorAndNewAttemptStartsAtPageOneWithItsOwnStageBatch() { + original.setFenceToken(lease.fence()); + when(runs.selectCount(any())).thenReturn(1L); + service.fail(1L, 20L, "SELFOP_STOCK_DUPLICATE", true, lease); + assertEquals("FAILED", original.getStatus()); assertNull(task.getRoutingManifestJson()); + assertEquals("RETRY_WAIT", task.getStatus()); + // Verifies the final updateById cannot overwrite the explicit SQL-null cursor reset. + verify(tasks).updateById(argThat((SelfopSyncTask t) -> t.getRoutingManifestJson() == null)); + when(runs.selectOne(any())).thenReturn(null); + var fresh = service.begin(1L, lease); + assertNotEquals(original.getId(), fresh.getId()); assertEquals(2, fresh.getAttemptNo()); + assertEquals(90L, fresh.getBasePublishedRunId()); assertEquals(80L, fresh.getReferenceSalesRunId()); + var client = mock(LingxingAnalyticsClient.class); var stages = mock(SelfopSyncStageMapper.class); + var stock = new SelfopPayloads.Stock("demo", "customer", "A", "sku", 0, null, + BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(stock), 1, 2, 1L, 1)); + var tx = mock(TransactionTemplate.class); + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); callback.accept(mock(TransactionStatus.class)); return null; + }).when(tx).executeWithoutResult(any()); + var collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, service, config, tx); + assertTrue(collector.collect(task, fresh, lease)); + var saved = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages).insert(saved.capture()); + assertEquals(fresh.getId(), saved.getValue().getStageBatchId()); assertEquals(fresh.getId(), saved.getValue().getRunId()); + assertEquals(1, saved.getValue().getFirstPageNo()); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); + } + + @Test void exhaustedFailuresRetainOldPublishedPointerAndCannotCreateAnotherAttempt() { + original.setFenceToken(lease.fence()); + when(runs.selectCount(any())).thenReturn((long) config.getMaxAttempts()); + service.fail(1L, 20L, "SOURCE_TIMEOUT", true, lease); + assertEquals("DEAD", task.getStatus()); assertEquals("FAILED", original.getStatus()); + assertEquals(90L, inventory.getPublishedRunId()); assertEquals(11L, original.getBasePublishedRunId()); + assertThrows(IllegalStateException.class, () -> service.begin(1L, lease)); + verify(runs, never()).insert(any(SelfopSyncRun.class)); verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + + @Test void expiredFetchingAttemptStartsFreshWithoutReusingCursorEvenIfFailNeverRan() { + // Process dies after accepted page one but before failure bookkeeping can clear its task cursor. + String oldSnapshot = original.getScopeSnapshotJson(); var oldStarted = original.getFetchStartedAtUtc(); + when(runs.selectOne(any())).thenReturn(original, null); + var replacement = service.begin(1L, lease); + assertNotEquals(original.getId(), replacement.getId()); assertEquals(2, replacement.getAttemptNo()); + assertNull(task.getRoutingManifestJson()); assertEquals("ABORTED", original.getStatus()); + assertEquals("LEASE_EXPIRED", original.getErrorCode()); assertNotNull(original.getFinishedAtUtc()); + assertEquals(oldSnapshot, original.getScopeSnapshotJson()); assertEquals(oldStarted, original.getFetchStartedAtUtc()); + assertEquals(11L, original.getBasePublishedRunId()); assertEquals(12L, original.getReferenceSalesRunId()); + assertEquals(90L, replacement.getBasePublishedRunId()); assertEquals(80L, replacement.getReferenceSalesRunId()); + // Clearing NULL needs explicit SQL because default updateById omits null-valued properties. + var update = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(tasks).update(isNull(), update.capture()); + var wrapper = (com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper) update.getValue(); + assertTrue(wrapper.getSqlSet().contains("routing_manifest_json")); + assertTrue(wrapper.getParamNameValuePairs().values().stream().anyMatch(Objects::isNull)); + verify(runs).insert(replacement); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java new file mode 100644 index 000000000..fdb0608a9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.List; +import java.time.Instant; +import java.time.LocalDateTime; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class SelfopProgressServiceTest { + private SelfopSyncTask task(){var t=new SelfopSyncTask();t.setId(1L);t.setAttemptCount(1);t.setTaskRole("ACCOUNT_DISCOVERY");t.setDiscoveryScopeId(3L);return t;} + private SelfopSyncRun run(){var r=new SelfopSyncRun();r.setId(2L);r.setTaskId(1L);r.setAttemptNo(1);r.setFenceToken(8L);return r;} + @Test void repeatedCheckpointUsesCommittedCountAndReliablePhaseDenominatorOnly() { + var t=task();var r=run(); + SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",3,8L,1); + SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",3,8L,1); + var p=read(t.getProgressJson(),Map.class); + assertEquals(3,p.get("completed"));assertEquals("ACCOUNT_SHARED",p.get("progressScope"));assertEquals("TASK_CUMULATIVE",p.get("countBasis")); + assertEquals(37.5,((Number)p.get("phasePercent")).doubleValue());assertFalse(p.containsKey("totalPercent")); + assertNotNull(r.getHeartbeatAtUtc());assertTrue(t.getProgressJson().getBytes(StandardCharsets.UTF_8).length<=8192); + SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",2,null,2); + p=read(t.getProgressJson(),Map.class);assertNull(p.get("phasePercent"));assertEquals(false,p.get("totalKnown"));assertEquals(2,p.get("scanRound")); + } + @Test void inventoryRowsAndEmptyResultDoNotBecomeSkuCountOrWholeJobSuccess() { + var t=task();t.setTaskRole("WAREHOUSE_SYNC");var r=run(); + SelfopProgressProjection.applyCheckpoint(t,r,"INVENTORY","INVENTORY_ROW",0,0L,1); + var p=read(t.getProgressJson(),Map.class);assertNull(p.get("phasePercent"));assertEquals("ATTEMPT_ONLY",p.get("countBasis")); + assertEquals("WAREHOUSE",p.get("progressScope"));assertNull(t.getStatus()); + assertThrows(IllegalStateException.class,()->SelfopProgressProjection.applyCheckpoint(t,r,"INVENTORY","SKU",1,1L,1)); + } + @Test void frozenAttemptDoesNotFollowLaterAttemptProgress() { + var t=task();var r=run();SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,10L,1); + SelfopProgressProjection.freezeRun(t,r);String frozen=r.getProgressSnapshotJson(); + var next=run();next.setId(4L);next.setAttemptNo(2); + SelfopProgressProjection.applyCheckpoint(t,next,"DETAIL","ORDER",7,10L,1); + SelfopProgressProjection.freezeRun(t,r);assertEquals(frozen,r.getProgressSnapshotJson()); + var unfrozen=run();assertThrows(IllegalStateException.class,()->SelfopProgressProjection.freezeRun(t,unfrozen)); + } + @Test void terminalPhaseKeepsReliableStageHistoryAndUtcStringsForReadContract() { + var t=task();t.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));t.setQueryUntilUtc(t.getQueryFromUtc().plusDays(1));var r=run(); + SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,4L,1); + SelfopProgressProjection.applyCheckpoint(t,r,"SAMPLE_DONE","NONE",0,null,1); + var p=read(t.getProgressJson(),Map.class);assertFalse(p.containsKey("phases")); + var stages=(List)p.get("stages");assertEquals(2,stages.size());var detail=(Map)stages.get(0); + assertEquals("DETAIL",detail.get("phase"));assertEquals(4,detail.get("completed"));assertEquals(true,detail.get("totalKnown")); + assertEquals(Instant.parse("2026-10-11T16:00:00Z"),Instant.parse((String)detail.get("sliceFrom"))); + assertDoesNotThrow(()->Instant.parse((String)detail.get("stageStartedAt"))); + assertDoesNotThrow(()->Instant.parse((String)p.get("stageStartedAt"))); + } + @Test void staleFenceCannotChangeCheckpointOrHeartbeat() { + var locks=mock(SelfopCoordinationMapper.class);var tasks=mock(SelfopSyncTaskMapper.class);var runs=mock(SelfopSyncRunMapper.class);var leases=mock(SelfopLeaseService.class); + var lease=new SelfopLeaseService.Lease(3,"old",7,true);when(locks.lockTask(1)).thenReturn(task());when(locks.lockRun(2)).thenReturn(run()); + var service=new SelfopProgressService(locks,tasks,runs,leases); + assertThrows(IllegalStateException.class,()->service.checkpoint(1,2,lease,"DETAIL","ORDER",1,3L,1)); + assertThrows(IllegalStateException.class,()->service.heartbeat(1,2,lease)); + verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class)); + var order=inOrder(leases,locks);order.verify(leases).verify(lease);order.verify(locks).lockTask(1);order.verify(locks).lockRun(2); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java new file mode 100644 index 000000000..d946652fd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.function.Consumer; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SelfopReferenceRecoveryTest { + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopDiscoveryService discovery = mock(SelfopDiscoveryService.class); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopReferenceRecovery recovery = new SelfopReferenceRecovery( + stages, tasks, scopes, accounts, locks, leases, taskService, discovery, tx); + private final String key = "b".repeat(64); + private final Map held = new LinkedHashMap<>(); + private final SelfopLeaseService.Lease accountLease = new SelfopLeaseService.Lease(50, "account-worker", 2, true); + private final SelfopSyncTask original = new SelfopSyncTask(); + private final SelfopSyncTask refresh = new SelfopSyncTask(); + private final SelfopSyncStage latest = new SelfopSyncStage(); + private String returnedWarehouse = "B"; + + @BeforeEach + void prepare() { + original.setId(1L); original.setSourceAccount("demo"); original.setWarehouseCode("A"); + original.setExecutionPurpose("BUSINESS"); + original.setScopeHash("fixed-scope"); original.setApplyState("BLOCKED"); + original.setAffectedWarehouseCodesJson(json(List.of("A", "B"))); + var manifest = new SelfopRoutingManifest(); + var ref = new SelfopRoutingManifest.Entry(); + ref.setEffectiveStageId(20L); ref.setCustomerCode("customer"); ref.setDeliveryNo("delivery"); ref.setWarehouseCode("A"); + manifest.getObjects().put(key, ref); original.setRoutingManifestJson(json(manifest)); + var current = new SelfopSyncStage(); current.setId(20L); current.setObservationSequence(1L); + latest.setId(21L); latest.setObservationSequence(2L); latest.setWarehouseCode("B"); + when(stages.selectById(20L)).thenReturn(current); when(stages.selectOne(any())).thenReturn(latest); + var account = new SelfopOutboundDiscoveryScope(); account.setId(50L); + when(accounts.selectOne(any())).thenReturn(account); + when(leases.acquireDiscovery(eq(50L), anyString())).thenReturn(accountLease); + for (long id : new long[]{10, 11}) { + held.put(id, new SelfopLeaseService.Lease(id, "warehouse-worker", 3, false)); + var scope = new SelfopSyncScope(); scope.setWarehouseCode(id == 10 ? "A" : "B"); + when(scopes.selectById(id)).thenReturn(scope); + } + refresh.setId(40L); refresh.setStatus("READY"); refresh.setExecutionPurpose("BUSINESS"); + when(taskService.create(any(SelfopSyncTask.class))).thenReturn(refresh); + when(tasks.selectById(40L)).thenReturn(refresh); + var run = new SelfopSyncRun(); run.setId(80L); run.setExecutionPurpose("BUSINESS"); + when(taskService.begin(40L, accountLease)).thenReturn(run); + when(locks.lockTask(1L)).thenReturn(original); + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); return null; + }).when(tx).executeWithoutResult(any()); + doAnswer(invocation -> { + var newManifest = new SelfopRoutingManifest(); var result = new SelfopRoutingManifest.Entry(); + result.setEffectiveStageId(22L); result.setWarehouseCode(returnedWarehouse); + newManifest.getObjects().put(key, result); + refresh.setRoutingManifestJson(json(newManifest)); refresh.setStatus("SUCCEEDED"); return null; + }).when(discovery).collect(refresh, run, accountLease); + } + + @Test + void newerObservationRequiresActualRecheckAndKeepsOriginalHalfApplyBarrier() { + recovery.refresh(original, held); + var reference = read(original.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key); + assertEquals(22L, reference.getEffectiveStageId()); assertEquals("B", reference.getWarehouseCode()); + assertEquals(40L, reference.getRefreshTaskId()); assertEquals(2L, reference.getNewestSeenSequence()); + assertEquals("BLOCKED", original.getApplyState()); + assertEquals(json(List.of("A", "B")), original.getAffectedWarehouseCodesJson()); + verify(taskService).create(argThat(t -> "ACCOUNT_DISCOVERY".equals(t.getTaskRole()) + && "RECHECK".equals(t.getTaskType()) + && Objects.equals(1L, read(t.getQueryScopeJson(), SelfopDiscoveryService.Query.class).parentTaskId()))); + verify(discovery).collect(eq(refresh), any(SelfopSyncRun.class), eq(accountLease)); + verify(tasks).updateById(original); verify(leases).release(accountLease); + } + + @Test + void unchangedObservationDoesNotRequestAnotherSourceRead() { + latest.setObservationSequence(1L); + var before = original.getRoutingManifestJson(); recovery.refresh(original, held); + assertEquals(before, original.getRoutingManifestJson()); + verifyNoInteractions(taskService, discovery, accounts, locks, leases); + } + + @Test + void budgetYieldCannotReplaceEffectiveReferenceOrClearBarrier() { + doAnswer(invocation -> { refresh.setStatus("RETRY_WAIT"); return null; }) + .when(discovery).collect(eq(refresh), any(SelfopSyncRun.class), eq(accountLease)); + var before = original.getRoutingManifestJson(); + var error = assertThrows(IllegalStateException.class, () -> recovery.refresh(original, held)); + assertEquals("SELFOP_RECHECK_YIELDED", error.getMessage()); + assertEquals(before, original.getRoutingManifestJson()); assertEquals("BLOCKED", original.getApplyState()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); verify(leases).release(accountLease); + } + + @Test + void recheckMovingToAnUnleasedWarehouseMustReacquireBeforeAnyParentWrite() { + returnedWarehouse = "C"; + var before = original.getRoutingManifestJson(); + var error = assertThrows(IllegalStateException.class, () -> recovery.refresh(original, held)); + assertEquals("LOCK_SET_CHANGED", error.getMessage()); + assertEquals(before, original.getRoutingManifestJson()); assertEquals("BLOCKED", original.getApplyState()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); verify(leases).release(accountLease); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java new file mode 100644 index 000000000..d65b78963 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncScopeMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncRunMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Gate assertions are offline; actual transaction rollback/concurrency needs database acceptance. */ +class SelfopSalesPublisherTest { + final SelfopCoordinationMapper locks=mock(SelfopCoordinationMapper.class); + final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class); + final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties(); + final OutboundAggregateMapper aggregates=mock(OutboundAggregateMapper.class); + final SelfopSalesPublisher publisher=new SelfopSalesPublisher(locks,scopes,runs,tasks,config,aggregates); + SelfopSyncScope scope; SelfopSyncRun run; SelfopSyncTask task; + + @BeforeEach void setup() { + scope=new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode("synthetic-warehouse"); + scope.setDataFamily("OUTBOUND"); scope.setScopeHash("confirmed-synthetic-scope"); scope.setSourceTimezone("UTC"); + scope.setLeaseOwner("worker"); scope.setFenceToken(3L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); + scope.setFactRevision(7L); scope.setPublishedFactRevision(6L); scope.setPublishedRunId(9L); scope.setRebuildRequired(1); + run=new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\",\"sourceTimezone\":\"UTC\"}"); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash(scope.getScopeHash()); + run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L); + run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2)); + run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0)); + task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED"); + when(locks.lockScope(1L)).thenReturn(scope); when(locks.lockRun(10L)).thenReturn(run); when(locks.lockTask(20L)).thenReturn(task); + } + private void enableSyntheticPublication() { config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSalesPublicationEnabled(true); config.setQuantityUnit("PCS"); } + private void noWrites() { verifyNoInteractions(scopes,runs,tasks); assertEquals(9L,scope.getPublishedRunId()); } + + @Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() { + assertFalse(config.isSalesPublicationEnabled()); + IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("SELFOP_SALES_PUBLICATION_DISABLED",error.getMessage()); verifyNoInteractions(locks); noWrites(); + } + @Test void expiredLeaseCannotSwitchPointer() { + enableSyntheticPublication(); scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1)); + assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites(); + } + @Test void deadHalfAppliedTaskStillBlocksUnrelatedReadyCandidate() { + enableSyntheticPublication(); task.setApplyState("BLOCKED"); task.setStatus("DEAD"); + when(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())).thenReturn(1L); + IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("HALF_APPLIED_FACTS",error.getMessage()); noWrites(); + verify(locks,never()).lockTask(20L); + } + @Test void revisionMismatchRejectsOtherwiseCompleteCandidate() { + enableSyntheticPublication(); scope.setFactRevision(8L); + IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("SELFOP_CANDIDATE_OBSOLETE",error.getMessage()); noWrites(); + } + @Test void newObservationRejectsCandidateEvenWithoutFactRevisionChange() { + enableSyntheticPublication(); + when(aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())).thenReturn(1L); + var error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("SELFOP_CANDIDATE_OBSERVATION_CHANGED",error.getMessage());noWrites(); + verify(locks,never()).lockTask(20L); + } + @Test void unconfirmedSourceTimezoneOrUnitCannotPublish() { + enableSyntheticPublication(); scope.setSourceTimezone(null); + assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites(); + scope.setSourceTimezone("UTC"); config.setQuantityUnit(null); + assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites(); + } + @Test void completeCandidatePublishesScopeRunAndTaskInsideOneTransactionBoundary() throws Exception { + enableSyntheticPublication(); + publisher.publish(1,10,"worker",3); + assertEquals(10L,scope.getPublishedRunId()); assertEquals(7L,scope.getPublishedFactRevision()); assertEquals(0,scope.getRebuildRequired()); + assertEquals(run.getCoveredFromUtc(),scope.getDiscoveryFromUtc()); assertEquals(run.getCoveredUntilUtc(),scope.getDiscoveryUntilUtc()); + assertEquals("PUBLISHED",run.getStatus()); assertNotNull(run.getPublishedAtUtc()); assertEquals("SUCCEEDED",task.getStatus()); + var ordered=inOrder(locks,scopes,runs,tasks); + ordered.verify(locks).lockScope(1L); ordered.verify(locks).lockRun(10L); + ordered.verify(locks).barriers(scope.getSourceAccount(),scope.getWarehouseCode()); ordered.verify(locks).lockTask(20L); + ordered.verify(runs).update(isNull(),any()); + ordered.verify(scopes).updateById(any(SelfopSyncScope.class)); + ordered.verify(runs).updateById(any(SelfopSyncRun.class)); ordered.verify(tasks).updateById(any(SelfopSyncTask.class)); + assertNotNull(SelfopSalesPublisher.class.getMethod("publish",long.class,long.class,String.class,long.class).getAnnotation(Transactional.class)); + assertEquals(10L,task.getResultPublishedRunId());assertNotNull(run.getProgressSnapshotJson()); + // Publisher constructor has no inventory repositories or inventory publication prerequisite. + verifyNoMoreInteractions(scopes,runs,tasks); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java new file mode 100644 index 000000000..39d8eefe2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java @@ -0,0 +1,240 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SelfopSchedulePlannerTest { + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopExecutionService execution = mock(SelfopExecutionService.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopOutboundOrderMapper orders = mock(SelfopOutboundOrderMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopSchedulePlanner planner = new SelfopSchedulePlanner(config, taskService, execution, + scopes, accounts, tasks, orders, locks, tx); + + @Test void disabledTickDoesNotInitializePlanPersistOrExecuteAnything() { + planner.tick(); + verifyNoInteractions(taskService, execution, scopes, accounts, tasks, orders, locks, tx); + } + + @Test void unknownSourceTimezoneStillPlansBothInventoriesAndExecutesDurableDueWork() { + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); + assertThat(config.getSourceQueryTimezone()).isNull(); + var a = scope(10L, "WH-A", "INVENTORY"); + var b = scope(11L, "WH-B", "INVENTORY"); + var outbound = scope(12L, "WH-A", "OUTBOUND"); + outbound.setSourceTimezone(null); + var dueA = new SelfopSyncTask(); dueA.setId(101L); + var dueB = new SelfopSyncTask(); dueB.setId(102L); + when(taskService.initialize()).thenReturn(List.of(a, b, outbound)); + when(taskService.due(10)).thenReturn(List.of(dueA, dueB)); + var subject = spy(planner); + doNothing().when(subject).plan(any(SelfopSyncScope.class), any(LocalDateTime.class)); + doNothing().when(subject).review(any(SelfopSyncScope.class), any(LocalDateTime.class)); + // A busy warehouse must not suppress the other warehouse's independent durable work. + doThrow(new IllegalStateException("SELFOP_LEASE_BUSY")).when(execution).execute(101L); + subject.tick(); + verify(subject).plan(eq(a), any(LocalDateTime.class)); + verify(subject).plan(eq(b), any(LocalDateTime.class)); + verify(subject, never()).plan(eq(outbound), any(LocalDateTime.class)); + verify(subject, never()).review(any(SelfopSyncScope.class), any(LocalDateTime.class)); + verify(execution).execute(101L); + verify(execution).execute(102L); + verifyNoInteractions(accounts, orders); + } + + @Test void firstInventoryCreatedAfterDailySlotSchedulesCurrentObservationInsteadOfYesterday() { + directTransactions(); + var scope = scope(10L, "WH-A", "INVENTORY"); + var now = LocalDateTime.of(2026, 10, 12, 14, 30); + scope.setCreatedAt(now.minusHours(1)); + when(locks.lockScope(10L)).thenReturn(scope); + planner.plan(scope, now); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(taskService).create(captured.capture()); + var task = captured.getValue(); + assertThat(task.getTaskType()).isEqualTo("SCHEDULED"); + assertThat(task.getPlannedAtUtc()).isEqualTo(now); + assertThat(task.getWarehouseCode()).isEqualTo("WH-A"); + assertThat(task.getScopeId()).isEqualTo(10L); + assertThat(task.getQueryFromUtc()).isNull(); + assertThat(task.getQueryUntilUtc()).isNull(); + assertThat(scope.getInitialFromUtc()).isNull(); + assertThat(scope.getInitialUntilUtc()).isNull(); + verifyNoInteractions(execution, accounts, orders); + } + + @Test void missedInventorySlotsPersistLatestDuePlanWithoutPretendingToObserveHistoricalStock() { + directTransactions(); + var scope = scope(10L, "WH-A", "INVENTORY"); + scope.setCreatedAt(LocalDateTime.of(2026, 10, 1, 0, 0)); + var previous = new SelfopSyncTask(); + previous.setPlannedAtUtc(LocalDateTime.of(2026, 10, 10, 8, 0)); + when(locks.lockScope(10L)).thenReturn(scope); + when(tasks.selectOne(any())).thenReturn(previous); + planner.plan(scope, LocalDateTime.of(2026, 10, 12, 14, 30)); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(taskService).create(captured.capture()); + var task = captured.getValue(); + assertThat(task.getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026, 10, 12, 8, 0)); + assertThat(task.getRequestKey()).contains("WH-A", "INVENTORY", "2026-10-12T08:00"); + assertThat(task.getQueryFromUtc()).isNull(); + assertThat(task.getQueryUntilUtc()).isNull(); + assertThat(task.getRoutingManifestJson()).isNull(); + // Planning creates no execution run or source observation; actual fetch records those later. + verifyNoInteractions(execution, accounts, orders); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + + @Test void completedKnownOrderAndHistoryRoundsRemainClearedThroughFinalScopeWrite() { + directTransactions(); + var scope = scope(10L, "WH-A", "OUTBOUND"); + var now = LocalDateTime.of(2026, 10, 12, 14, 30); + scope.setKnownOrderCursorId(99L); + scope.setKnownOrderRoundMaxId(99L); + scope.setKnownOrderRoundStartedAtUtc(now.minusDays(1)); + scope.setInitialFromUtc(now.minusMonths(2)); + scope.setDiscoveryUntilUtc(now.minusHours(1)); + scope.setHistoryRoundFromUtc(scope.getInitialFromUtc()); + scope.setHistoryRoundUntilUtc(scope.getDiscoveryUntilUtc()); + scope.setHistorySliceCursorUtc(scope.getDiscoveryUntilUtc()); + when(locks.lockScope(10L)).thenReturn(scope); + // No further known order inside this fixed round; the history cursor is already at its end. + when(orders.selectOne(any())).thenReturn(null); + planner.review(scope, now); + var captured = ArgumentCaptor.forClass(SelfopSyncScope.class); + verify(scopes).updateById(captured.capture()); + var persisted = captured.getValue(); + assertThat(persisted.getKnownOrderCursorId()).isNull(); + assertThat(persisted.getKnownOrderRoundMaxId()).isNull(); + assertThat(persisted.getKnownOrderRoundFinishedAtUtc()).isEqualTo(now); + assertThat(persisted.getHistoryRoundUntilUtc()).isNull(); + assertThat(persisted.getHistorySliceCursorUtc()).isNull(); + assertThat(persisted.getHistoryRoundFinishedAtUtc()).isEqualTo(now); + verify(scopes, times(2)).update(isNull(), any()); + verifyNoInteractions(taskService, execution, accounts, tasks); + } + + @Test void unpublishedInitialBackfillIsNotRepeatedAcrossEightAndTwentySlots() { + var initial=LocalDateTime.of(2026,10,12,0,0); // Beijing 08:00 + var scope=outboundScope(initial);var state=persistPlans(scope); + planner.plan(scope,initial); + var history=state.created.stream().filter(t->"BACKFILL".equals(t.getTaskType())).toList(); + assertThat(history).isNotEmpty(); + assertThat(history.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,0,0)); + assertThat(history.get(history.size()-1).getQueryUntilUtc()).isEqualTo(initial); + assertThat(history).allSatisfy(t->assertThat(t.getRequestKey()).startsWith("initial:")); + int firstCount=state.created.size(); + var evening=initial.plusHours(12); + planner.plan(scope,evening); + var second=state.created.subList(firstCount,state.created.size()); + assertThat(second).noneSatisfy(t->assertThat(t.getTaskType()).isEqualTo("BACKFILL")); + var increments=second.stream().filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())).toList(); + assertThat(increments).hasSize(1); + assertThat(increments.get(0).getTaskType()).isEqualTo("SCHEDULED"); + assertThat(increments.get(0).getQueryFromUtc()).isEqualTo(initial); + assertThat(increments.get(0).getQueryUntilUtc()).isEqualTo(evening); + assertThat(scope.getDiscoveryUntilUtc()).isNull(); // No source execution or publication occurred. + assertThat(scope.getInitialUntilUtc()).isEqualTo(initial); + int afterEvening=state.created.size(); + planner.plan(scope,evening.plusMinutes(1)); + assertThat(state.created).hasSize(afterEvening); + assertThat(state.created.stream().map(SelfopSyncTask::getRequestKey).toList()).doesNotHaveDuplicates(); + } + + @Test void persistedRollingMonthWindowKeepsItsBoundaryWhenFirstPlanningIsDelayed() { + var initial=LocalDateTime.of(2026,3,31,0,0); + var scope=outboundScope(initial);scope.setInitialUntilUtc(initial); + scope.setInitialFromUtc(LocalDateTime.of(2026,1,31,0,0)); + var state=persistPlans(scope); + planner.plan(scope,initial.plusHours(12)); + var history=state.created.stream().filter(t->"BACKFILL".equals(t.getTaskType())).toList(); + assertThat(history).hasSize(59); // Calendar months, not a fixed sixty-day subtraction. + assertThat(history.get(0).getQueryFromUtc()).isEqualTo(scope.getInitialFromUtc()); + assertThat(history.get(58).getQueryUntilUtc()).isEqualTo(initial); + assertThat(history).allSatisfy(t->assertThat(t.getQueryUntilUtc()).isBeforeOrEqualTo(initial)); + var incremental=state.created.stream().filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())&&"SCHEDULED".equals(t.getTaskType())).toList(); + assertThat(incremental).hasSize(1); + assertThat(incremental.get(0).getQueryFromUtc()).isEqualTo(initial); + assertThat(incremental.get(0).getQueryUntilUtc()).isEqualTo(initial.plusHours(12)); + assertThat(scope.getInitialUntilUtc()).isEqualTo(initial); + } + + @Test void missedLaterSlotsQueueEveryIncrementalIntervalWithoutMovingInitialWindow() { + var initial=LocalDateTime.of(2026,10,12,0,0);var scope=outboundScope(initial); + var state=persistPlans(scope);planner.plan(scope,initial); + int before=state.created.size(); + planner.plan(scope,initial.plusDays(4).plusHours(12)); + var incremental=state.created.subList(before,state.created.size()).stream() + .filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())).toList(); + assertThat(incremental).hasSize(5); + var cursor=initial; + for(var slice:incremental){ + assertThat(slice.getTaskType()).isEqualTo("SCHEDULED"); + assertThat(slice.getQueryFromUtc()).isEqualTo(cursor); + assertThat(slice.getQueryUntilUtc()).isAfter(cursor).isBeforeOrEqualTo(cursor.plusDays(1)); + cursor=slice.getQueryUntilUtc(); + } + assertThat(cursor).isEqualTo(initial.plusDays(4).plusHours(12)); + assertThat(scope.getInitialFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,0,0)); + assertThat(scope.getInitialUntilUtc()).isEqualTo(initial); + } + + private SelfopSyncScope outboundScope(LocalDateTime created) { + var scope=scope(10L,"WH-A","OUTBOUND");scope.setCreatedAt(created); + scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai"); + scope.setScheduleCron("0 0 8,20 * * *");return scope; + } + private static class PlannedState { + final List created=new ArrayList<>(); + final Map byKey=new HashMap<>(); + SelfopSyncTask latest; + } + private PlannedState persistPlans(SelfopSyncScope scope) { + directTransactions();config.setSourceQueryTimezone("Asia/Shanghai"); + config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + var account=new SelfopOutboundDiscoveryScope();account.setId(30L); + when(accounts.selectOne(any())).thenReturn(account);when(locks.lockScope(scope.getId())).thenReturn(scope); + var state=new PlannedState();when(tasks.selectOne(any())).thenAnswer(call->state.latest); + when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(call->{ + SelfopSyncTask task=call.getArgument(0);var prior=state.byKey.get(task.getRequestKey()); + if(prior!=null){assertThat(task.getRequestHash()).isEqualTo(prior.getRequestHash());return prior;} + state.byKey.put(task.getRequestKey(),task);state.created.add(task); + if("WAREHOUSE_SYNC".equals(task.getTaskRole()))state.latest=task; + return task; + });return state; + } + + private SelfopSyncScope scope(long id, String warehouse, String family) { + var scope = new SelfopSyncScope(); + scope.setId(id); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode(warehouse); + scope.setDataFamily(family); scope.setBusinessTimezone("UTC"); scope.setScheduleCron("0 0 8 * * *"); + scope.setScopeHash("synthetic-scope-hash"); + scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\",\"qualities\":\"ALL\"}"); + return scope; + } + private void directTransactions() { + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); + doAnswer(call -> { + Consumer action = call.getArgument(0); + action.accept(mock(TransactionStatus.class)); + return null; + }).when(tx).executeWithoutResult(any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java new file mode 100644 index 000000000..cca66f8c9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java @@ -0,0 +1,346 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.dao.DuplicateKeyException; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SelfopTaskServiceTest { + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopTaskService service = new SelfopTaskService(scopes, accounts, tasks, runs, locks, config, leases); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(10L, "synthetic-worker", 7L, false); + + @BeforeEach void configureBusiness() {config.setCollectionMode("BUSINESS");config.setEnvironment("production");} + + @Test void beginFixesTopLevelTimezoneStalenessAndInitialWindowInVersionSnapshot() { + var task = task("READY"); + var scope = scope("OUTBOUND"); + scope.setInitialFromUtc(LocalDateTime.of(2026, 9, 12, 0, 0)); + scope.setInitialUntilUtc(LocalDateTime.of(2026, 10, 12, 0, 0)); + when(locks.lockTask(1L)).thenReturn(task); + when(scopes.selectById(10L)).thenReturn(scope); + + var run = service.begin(1L, lease); + Map snapshot = read(run.getScopeSnapshotJson(), Map.class); + assertThat(snapshot.get("businessTimezone")).isEqualTo("America/New_York"); + assertThat(snapshot.get("sourceTimezone")).isEqualTo("UTC"); + assertThat(snapshot.get("staleAfterSeconds")).isEqualTo(43200); + assertThat(snapshot.get("initialFromUtc")).isEqualTo("2026-09-12T00:00:00"); + assertThat(snapshot.get("initialUntilUtc")).isEqualTo("2026-10-12T00:00:00"); + assertThat(snapshot.get("customerMode")).isEqualTo("ALL_AUTHORIZED"); + assertThat(run.getBasePublishedRunId()).isEqualTo(90L); + assertThat(run.getFenceToken()).isEqualTo(7L); + assertThat(run.getAttemptNo()).isEqualTo(1); + var order = inOrder(leases, locks, runs, tasks); + order.verify(leases).verify(lease); + order.verify(locks).lockTask(1L); + order.verify(runs).selectOne(any()); + order.verify(runs).insert(run); + order.verify(tasks).updateById(task); + } + + @Test void unknownSourceTimezoneAndUnestablishedInitialWindowStayExplicitlyUnknown() { + var scope = scope("OUTBOUND"); + scope.setSourceTimezone(null); + when(locks.lockTask(1L)).thenReturn(task("READY")); + when(scopes.selectById(10L)).thenReturn(scope); + Map snapshot = read(service.begin(1L, lease).getScopeSnapshotJson(), Map.class); + assertThat(snapshot.containsKey("sourceTimezone")).isTrue(); + assertThat(snapshot.containsKey("initialFromUtc")).isTrue(); + assertThat(snapshot.containsKey("initialUntilUtc")).isTrue(); + assertThat(snapshot.get("sourceTimezone")).isNull(); + assertThat(snapshot.get("initialFromUtc")).isNull(); + assertThat(snapshot.get("initialUntilUtc")).isNull(); + } + + @Test void staleFenceRejectsBeginBeforeAnyTaskLockOrAttemptMutation() { + doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).verify(lease); + assertThatThrownBy(() -> service.begin(1L, lease)).hasMessage("SELFOP_FENCE_REJECTED"); + verifyNoInteractions(locks, tasks, runs, scopes, accounts); + } + + @Test void validLeaseForAnotherWarehouseScopeCannotBeginThisTask() { + when(locks.lockTask(1L)).thenReturn(task("READY")); + var wrongScope = new SelfopLeaseService.Lease(11L, "synthetic-worker", 7L, false); + assertThatThrownBy(() -> service.begin(1L, wrongScope)).hasMessage("SELFOP_TASK_LEASE_SCOPE_MISMATCH"); + verify(leases).verify(wrongScope); + verifyNoInteractions(tasks, runs, scopes, accounts); + } + + @Test void sameManualRetryCommandDoesNotResetRunningOrFailedOriginalTask() { + for (String status : List.of("RUNNING", "FAILED")) { + reset(locks, tasks, accounts, runs, scopes); + var original = task(status); + original.setApplyState("BLOCKED"); + original.setAttemptCount(8); + original.setRequestedBy("previous-operator"); + var retryAudit = task("SUCCEEDED"); + retryAudit.setRequestKey("retry:command-1"); + retryAudit.setRequestHash(hash(List.of(1L, "command-1", "offline retry"))); + when(tasks.selectById(1L)).thenReturn(original); + when(tasks.selectOne(any())).thenReturn(retryAudit); + + assertThat(service.retry(1L, "command-1", "new-operator", "offline retry")).isSameAs(original); + assertThat(original.getStatus()).isEqualTo(status); + assertThat(original.getApplyState()).isEqualTo("BLOCKED"); + assertThat(original.getAttemptCount()).isEqualTo(8); + assertThat(original.getRequestedBy()).isEqualTo("previous-operator"); + assertThat(original.getNextRetryAtUtc()).isNull(); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + } + + @Test void duplicateRetryKeyForAnotherTaskIsRefusedWithoutChangingOriginal() { + var original = task("RUNNING"); + var prior = task("SUCCEEDED"); + prior.setRequestHash(hash(List.of(2L, "command-1", "offline retry"))); + when(tasks.selectById(1L)).thenReturn(original); + when(tasks.selectOne(any())).thenReturn(prior); + assertThatThrownBy(() -> service.retry(1L, "command-1", "operator", "offline retry")) + .hasMessage("SELFOP_IDEMPOTENCY_CONFLICT"); + assertThat(original.getStatus()).isEqualTo("RUNNING"); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + + @Test void firstRetryLocksWarehouseScopeBeforeTaskAndPreservesDurableApplyBarrier() { + var original = task("DEAD"); + original.setApplyState("BLOCKED"); + original.setAttemptCount(5); + var unlockedScope = scope("OUTBOUND"); + when(tasks.selectById(1L)).thenReturn(original); + when(locks.lockScope(10L)).thenReturn(unlockedScope); + when(locks.lockTask(1L)).thenReturn(original); + assertThat(service.retry(1L, "first-command", "new-operator", "offline retry")).isSameAs(original); + var order = inOrder(locks); + order.verify(locks).lockScope(10L); + order.verify(locks).lockTask(1L); + assertThat(original.getStatus()).isEqualTo("READY"); + assertThat(original.getApplyState()).isEqualTo("BLOCKED"); + assertThat(original.getAttemptCount()).isEqualTo(5); + var audit = org.mockito.ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(tasks).insert(audit.capture()); + assertThat(audit.getValue().getRequestKey()).isEqualTo("retry:first-command"); + assertThat(audit.getValue().getStatus()).isEqualTo("SUCCEEDED"); + assertThat(audit.getValue().getUpstreamTaskId()).isEqualTo(1L); + assertThat(original.getTaskNo()).isEqualTo("LXCOL-20261012-00000001"); + assertThat(read(audit.getValue().getQueryScopeJson(),Map.class)).containsEntry("reason","offline retry") + .containsEntry("resumesTaskNo",original.getTaskNo()); + verify(tasks).updateById(original); + } + + @Test void firstRetryCannotResetTaskWhileScopeHasLiveExecutionLease() { + var original = task("RUNNING"); + original.setApplyState("APPLYING"); + var liveScope = scope("OUTBOUND"); + liveScope.setLeaseOwner("active-worker"); + liveScope.setLeaseUntilUtc(utcNow().plusDays(1)); + when(tasks.selectById(1L)).thenReturn(original); + when(locks.lockScope(10L)).thenReturn(liveScope); + assertThatThrownBy(() -> service.retry(1L, "first-command", "new-operator", "offline retry")) + .hasMessage("SELFOP_RETRY_LEASE_BUSY"); + assertThat(original.getStatus()).isEqualTo("RUNNING"); + assertThat(original.getApplyState()).isEqualTo("APPLYING"); + assertThat(original.getNextRetryAtUtc()).isNull(); + verify(locks, never()).lockTask(anyLong()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verifyNoInteractions(accounts, runs, scopes); + } + + @Test void concurrentRetryAuditAppearingAfterScopeLockUsesCurrentReadAndDoesNotResetTask() { + var original = task("RUNNING"); + original.setApplyState("APPLYING"); + var audit = task("SUCCEEDED"); + audit.setRequestHash(hash(List.of(1L, "raced-command", "offline retry"))); + when(tasks.selectById(1L)).thenReturn(original); + when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND")); + int[] reads = {0}; + when(tasks.selectOne(any())).thenAnswer(call -> { + QueryWrapper query = call.getArgument(0); + if (++reads[0] == 1) { + assertThat(query.getSqlSegment()).doesNotContain("FOR UPDATE"); + return null; + } + assertThat(query.getSqlSegment()).endsWith("FOR UPDATE"); + return audit; + }); + assertThat(service.retry(1L, "raced-command", "new-operator", "offline retry")).isSameAs(original); + assertThat(reads[0]).isEqualTo(2); + assertThat(original.getStatus()).isEqualTo("RUNNING"); + assertThat(original.getApplyState()).isEqualTo("APPLYING"); + assertThat(original.getNextRetryAtUtc()).isNull(); + verify(locks).lockScope(10L); + verify(locks, never()).lockTask(anyLong()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + } + + @Test void duplicateInsertUsesCurrentReadAndReturnsExistingTaskWithoutResettingIt() { + var existing = task("RUNNING"); + existing.setApplyState("APPLYING"); + existing.setAttemptCount(8); + var incoming = task("READY"); + int[] reads = {0}; + when(tasks.selectOne(any())).thenAnswer(call -> { + QueryWrapper query = call.getArgument(0); + if (++reads[0] == 1) return null; + assertThat(query.getSqlSegment()).endsWith("FOR UPDATE"); + return existing; + }); + when(tasks.insert(any(SelfopSyncTask.class))).thenThrow(new DuplicateKeyException("synthetic duplicate")); + assertThat(service.create(incoming)).isSameAs(existing); + assertThat(reads[0]).isEqualTo(2); + assertThat(existing.getStatus()).isEqualTo("RUNNING"); + assertThat(existing.getApplyState()).isEqualTo("APPLYING"); + assertThat(existing.getAttemptCount()).isEqualTo(8); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + + @Test void createRefusesSameRequestKeyWithDifferentPayloadWithoutAllocatingOrWriting() { + var existing = task("RUNNING"); + existing.setRequestHash("existing-hash"); + var incoming = task("READY"); + incoming.setRequestHash("different-hash"); + when(tasks.selectOne(any())).thenReturn(existing); + assertThatThrownBy(() -> service.create(incoming)).hasMessage("SELFOP_IDEMPOTENCY_CONFLICT"); + assertThat(incoming.getAttemptCount()).isEqualTo(0); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + + @Test void lostLeaseFailCannotTouchCurrentRunOrTask() { + doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).verify(lease); + assertThatThrownBy(() -> service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease)) + .hasMessage("SELFOP_FENCE_REJECTED"); + verifyNoInteractions(locks, tasks, runs, scopes, accounts); + } + + @Test void oldRunFenceCannotMutateWithOtherwiseValidCurrentScopeLease() { + var task = task("RUNNING"); + var oldRun = run("FETCHING"); + oldRun.setFenceToken(6L); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(oldRun); + assertThatThrownBy(() -> service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease)) + .hasMessage("SELFOP_RUN_LEASE_MISMATCH"); + verify(leases).verify(lease); + assertThat(task.getStatus()).isEqualTo("RUNNING"); + assertThat(oldRun.getStatus()).isEqualTo("FETCHING"); + assertThat(oldRun.getErrorCode()).isNull(); + verifyNoInteractions(tasks, runs, scopes, accounts); + } + + @Test void budgetYieldDoesNotBecomeFailedAttemptOrClearApplyBarrier() { + var task = task("RUNNING"); + task.setAttemptCount(99); + task.setApplyState("APPLYING"); + var run = run("FETCHING"); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(run); + service.yield(1L, 20L, lease); + assertThat(run.getStatus()).isEqualTo("YIELDED"); + assertThat(run.getErrorCode()).isNull(); + assertThat(task.getStatus()).isEqualTo("RETRY_WAIT"); + assertThat(task.getAttemptCount()).isEqualTo(99); + assertThat(task.getApplyState()).isEqualTo("APPLYING"); + assertThat(task.getNextRetryAtUtc()).isNotNull(); + verify(runs, never()).selectCount(any()); + verify(runs).updateById(run); + verify(tasks).updateById(task); + } + + @Test void finiteFailureBudgetCountsFailuresInsteadOfManyYieldedAttempts() { + config.setMaxAttempts(3); + var task = task("RUNNING"); + task.setAttemptCount(99); + task.setApplyState("APPLYING"); + var run = run("FETCHING"); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(run); + when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND")); + when(runs.selectCount(any())).thenAnswer(call -> { + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper query = call.getArgument(0); + query.getSqlSegment(); + assertThat(query.getParamNameValuePairs()).containsValue("FAILED").doesNotContainValue("YIELDED"); + return 2L; + }); + service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease); + assertThat(task.getStatus()).isEqualTo("RETRY_WAIT"); + assertThat(task.getApplyState()).isEqualTo("BLOCKED"); + assertThat(run.getStatus()).isEqualTo("FAILED"); + assertThat(run.getLastPhase()).isEqualTo("FETCHING"); + assertThat(run.getErrorCode()).isEqualTo("SOURCE_TEMPORARY"); + verify(runs).selectCount(any()); + } + + @Test void exhaustedFiniteFailuresBecomeDeadAndKeepBlockedApplyState() { + config.setMaxAttempts(3); + var task = task("RUNNING"); + task.setApplyState("APPLYING"); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(run("BUILDING")); + when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND")); + when(runs.selectCount(any())).thenReturn(3L); + service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease); + assertThat(task.getStatus()).isEqualTo("DEAD"); + assertThat(task.getApplyState()).isEqualTo("BLOCKED"); + } + + @Test void validationChildCannotEnterBusinessFailureOrManualRetryQueue() { + var child=task("RUNNING");child.setExecutionPurpose("VALIDATION");child.setValidationRootTaskId(9L); + var attempt=run("FETCHING");attempt.setExecutionPurpose("VALIDATION"); + when(locks.lockTask(1L)).thenReturn(child);when(locks.lockRun(20L)).thenReturn(attempt); + when(tasks.selectById(1L)).thenReturn(child);when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND")); + assertThatThrownBy(()->service.fail(1L,20L,"VALIDATION_STRUCTURE_REJECTED",true,lease)) + .hasMessage("SELFOP_BUSINESS_PURPOSE_REQUIRED"); + assertThatThrownBy(()->service.retry(1L,"validation-retry","operator","offline retry")) + .hasMessage("SELFOP_BUSINESS_PURPOSE_REQUIRED"); + assertThat(child.getStatus()).isEqualTo("RUNNING");assertThat(child.getNextRetryAtUtc()).isNull(); + assertThat(attempt.getStatus()).isEqualTo("FETCHING"); + verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(tasks,never()).insert(any(SelfopSyncTask.class)); + verify(runs,never()).updateById(any(SelfopSyncRun.class));verify(runs,never()).selectCount(any()); + } + + private SelfopSyncTask task(String status) { + var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); + task.setId(1L); task.setScopeId(10L); task.setSourceAccount("synthetic-account"); + task.setWarehouseCode("WH-A"); task.setTaskRole("WAREHOUSE_SYNC"); + task.setRequestKey("slot-1"); task.setRequestHash("request-hash"); task.setScopeHash("scope-hash"); + task.setQueryScopeJson("{\"warehouseCode\":\"WH-A\"}"); + task.setStatus(status); task.setApplyState("NOT_STARTED"); task.setAttemptCount(0); + return task; + } + private SelfopSyncScope scope(String family) { + var scope = new SelfopSyncScope(); + scope.setId(10L); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode("WH-A"); + scope.setDataFamily(family); scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\"}"); + scope.setBusinessTimezone("America/New_York"); scope.setSourceTimezone("UTC"); + scope.setStaleAfterSeconds(43200); scope.setPublishedRunId(90L); + return scope; + } + private SelfopSyncRun run(String status) { + var run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); + run.setId(20L); run.setTaskId(1L); run.setScopeId(10L); run.setFenceToken(7L); run.setStatus(status); + return run; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java new file mode 100644 index 000000000..5c44540a6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java @@ -0,0 +1,156 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baiyue.lastleg.selfoperated.analytics.client.*; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Consumer; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** Offline callbacks model durable checkpoints, without pretending to test MySQL transaction rollback. */ +class SelfopValidationRunnerTest { + private final LingxingAnalyticsClient client=mock(LingxingAnalyticsClient.class); + private final SelfopSyncStageMapper stages=mock(SelfopSyncStageMapper.class); + private final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + private final SelfopCoordinationMapper locks=mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases=mock(SelfopLeaseService.class); + private final SelfopEgressService egress=mock(SelfopEgressService.class); + private final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties(); + private final TransactionTemplate tx=mock(TransactionTemplate.class); + private final SelfopLeaseService.Lease lease=new SelfopLeaseService.Lease(3,"owner",8,true); + private SelfopSyncTask task; + private SelfopSyncRun run; + private SelfopValidationRunner runner; + @BeforeEach void setup() { + config.setCollectionMode("VALIDATION");config.setSourceAccount("demo");config.setExclusiveGrantId("test-grant"); + config.getWarehouses().put("A",new SelfopAnalyticsProperties.Warehouse());config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse()); + task=new SelfopSyncTask();task.setId(1L);task.setValidationRootTaskId(1L);task.setTaskRole("ACCOUNT_DISCOVERY");task.setExecutionPurpose("VALIDATION"); + task.setSourceAccount("demo");task.setDiscoveryScopeId(3L);task.setAttemptCount(1);task.setStatus("RUNNING"); + task.setQueryScopeJson("{\"startTime\":\"2026-10-12T08:00:00\",\"endTime\":\"2026-10-12T08:10:00\"}"); + task.setValidationBudgetJson(SelfopValidationBudget.freeze(utcNow())); + run=new SelfopSyncRun();run.setId(2L);run.setTaskId(1L);run.setSourceAccount("demo");run.setExecutionPurpose("VALIDATION");run.setAttemptNo(1);run.setFenceToken(8L);run.setStatus("FETCHING"); + when(locks.lockTask(1)).thenReturn(task);when(locks.lockRun(2)).thenReturn(run);when(tasks.selectById(1L)).thenReturn(task); + doAnswer(i->{Consumer callback=i.getArgument(0);callback.accept(mock(TransactionStatus.class));return null;}).when(tx).executeWithoutResult(any()); + when(client.warehouses(any(SelfopRequestContext.class),eq(1),eq(10))).thenReturn(page(1,0,0,List.of())); + when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,0,0,List.of())); + when(client.inventory(any(SelfopRequestContext.class),anyString(),eq(1),eq(10))).thenReturn(page(1,0,0,List.of())); + runner=new SelfopValidationRunner(client,stages,tasks,runs,locks,leases,egress,config,tx); + } + private LingxingAnalyticsClient.Page page(int current,long total,int pages,List records){return new LingxingAnalyticsClient.Page<>(records,current,10,total,pages);} + private SelfopPayloads.Stock stock(String warehouse,String sku,BigDecimal qty){return new SelfopPayloads.Stock("demo","customer",warehouse,sku,0,"Name",qty,qty,BigDecimal.ZERO,BigDecimal.ZERO,null);} + private LingxingAnalyticsClient.Summary summary(String number){return new LingxingAnalyticsClient.Summary("demo","customer","A",number,"source",100,null,"2026-10-12 08:02:00");} + + @Test void truncatedTwoWarehouseSampleOnlyStagesAndDoesNotClaimCompleteCoverage() { + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("A","one",BigDecimal.ZERO)))); + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(2),eq(10))).thenReturn(page(2,30,3,List.of(stock("A","two",null)))); + when(client.inventory(any(SelfopRequestContext.class),eq("B"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("B","one",BigDecimal.ONE)))); + when(client.inventory(any(SelfopRequestContext.class),eq("B"),eq(2),eq(10))).thenReturn(page(2,30,3,List.of(stock("B","two",BigDecimal.ONE)))); + runner.run(task,run,lease); + assertEquals("SAMPLE_DONE",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus());assertNull(run.getPublishedAtUtc());assertNull(run.getCoveredUntilUtc()); + verify(client,never()).inventory(any(SelfopRequestContext.class),anyString(),eq(3),anyInt()); + var capture=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(4)).insert(capture.capture()); + assertTrue(capture.getAllValues().stream().allMatch(s->s.getObjectType().equals("PRODUCT_STOCK")&&s.getObservationSequence()==null)); + assertTrue(capture.getAllValues().stream().anyMatch(s->"PENDING".equals(s.getValidationStatus()))); + verify(egress).finishSample(1L,"VALIDATION_SAMPLE_SUFFICIENT"); + verify(tasks,never()).updateById(any(SelfopSyncTask.class)); // quota root JSON cannot be overwritten by an earlier task snapshot + } + @Test void emptyCompletePagesStillEndSampleAndNeverBecomeFormalVersion() { + runner.run(task,run,lease);assertEquals("SAMPLE_DONE",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus()); + assertNull(run.getPublishedAtUtc());assertNull(run.getAppliedFactRevision());verify(stages,never()).insert(any(SelfopSyncStage.class)); + assertNotNull(run.getProgressSnapshotJson()); + } + @Test void requestBudgetStopIsSampleDoneNotYieldAndNoSubsequentRequests() { + when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenThrow(new EgressRejectedException("VALIDATION_PAGE_LIMIT",true,null)); + runner.run(task,run,lease);assertEquals("SAMPLE_DONE",run.getStatus());verify(egress).finishSample(1L,"VALIDATION_PAGE_LIMIT"); + verify(client,never()).inventory(any(SelfopRequestContext.class),anyString(),anyInt(),anyInt()); + } + @Test void rateLimitFailsEntireRootAndDoesNotResumeOtherWarehouse() { + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenThrow(new AnalyticsClientException("LINGXING_WMS_RATE_LIMITED",true)); + runner.run(task,run,lease);assertEquals("FAILED",run.getStatus());verify(egress).failValidation(1L,"LINGXING_WMS_RATE_LIMITED"); + verify(client,never()).inventory(any(SelfopRequestContext.class),eq("B"),anyInt(),anyInt()); + } + @Test void uniqueDetailsRetainSkuQuantitiesAndIdentityValidation() throws Exception { + when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,1,1,List.of(summary("order")))); + var detail=new SelfopPayloads.Order("demo","customer","A","order","source",100,null,"2026-10-12 08:02:00",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS"))); + when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("order"))).thenReturn(detail); + runner.run(task,run,lease); + var capture=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(2)).insert(capture.capture()); + var row=capture.getAllValues().stream().filter(s->"OUTBOUND_ORDER".equals(s.getObjectType())&&"SAMPLE_VALIDATED".equals(s.getValidationCode())).findFirst().orElseThrow(); + assertEquals(BigDecimal.ONE,read(row.getPayloadJson(),SelfopPayloads.Order.class).items().get(0).actualQuantity()); + assertNull(row.getObservationSequence());assertEquals("SAMPLE_DONE",run.getStatus()); + assertEquals("PENDING",row.getValidationStatus()); + assertTrue(capture.getAllValues().stream().allMatch(s->s.getObjectType().length()<=16&& + (!"OUTBOUND_ORDER".equals(s.getObjectType())||!"VALID".equals(s.getValidationStatus())||s.getObservationSequence()!=null))); + try(var schema=getClass().getResourceAsStream("/db/lastleg.sql")) { + assertNotNull(schema); + String sql=new String(schema.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8); + assertTrue(sql.contains("object_type VARCHAR(16)")); + assertTrue(sql.contains("object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL")); + } + } + @Test void restartUsesPersistedSampleCursorWithoutRepeatingCompletedPagesOrDetails() { + var saved=new SelfopValidationRunner.Sample();saved.setWarehousesRead(true);saved.setDirectoryRead(true);saved.setDetailIndex(1); + saved.getOrders().add(summary("order"));saved.getInventoryFinished().add("A");saved.getInventoryNextPage().put("A",3); + task.setRoutingManifestJson(json(saved));runner.run(task,run,lease); + verify(client,never()).warehouses(any(SelfopRequestContext.class),anyInt(),anyInt()); + verify(client,never()).detail(any(SelfopRequestContext.class),anyString(),anyString(),anyString()); + verify(client,never()).inventory(any(SelfopRequestContext.class),eq("A"),anyInt(),anyInt()); + verify(client).inventory(any(SelfopRequestContext.class),eq("B"),eq(1),eq(10)); + assertEquals("SAMPLE_DONE",run.getStatus()); + } + @Test void wrongPurposeCannotCallAnyClient() { + task.setExecutionPurpose("BUSINESS");assertThrows(IllegalStateException.class,()->runner.run(task,run,lease));verifyNoInteractions(client,egress); + } + @Test void malformedPersistedSampleTerminatesRootBeforeAnyProviderRequest() { + task.setRoutingManifestJson("{invalid"); + runner.run(task,run,lease); + assertEquals("FAILED",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus()); + assertNull(run.getPublishedAtUtc());assertNull(run.getAppliedFactRevision()); + verify(egress).failValidation(1L,"VALIDATION_STRUCTURE_REJECTED"); + verifyNoInteractions(client,stages); + verify(tasks).update(isNull(),argThat((UpdateWrapper write)-> + write.getSqlSet().contains("next_retry_at_utc") + && write.getParamNameValuePairs().containsValue("DEAD") + && write.getParamNameValuePairs().containsValue(null))); + } + @Test void executionFailureOnChildTerminatesItsOriginalRootAndClearsChildRetry() { + task.setValidationRootTaskId(9L); + runner.fail(task,run,lease,"VALIDATION_STRUCTURE_REJECTED"); + assertEquals("FAILED",run.getStatus());assertNull(run.getPublishedAtUtc()); + verify(egress).failValidation(9L,"VALIDATION_STRUCTURE_REJECTED"); + verify(tasks).update(isNull(),argThat((UpdateWrapper write)-> + write.getSqlSet().contains("next_retry_at_utc") + && write.getParamNameValuePairs().containsValue("DEAD") + && write.getParamNameValuePairs().containsValue(null))); + verifyNoInteractions(client,stages); + } + @Test void oldExecutorCannotMarkNewAttemptFailed() { + doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).renew(lease); + assertThrows(IllegalStateException.class,()->runner.run(task,run,lease));verifyNoInteractions(egress);verifyNoInteractions(client); + } + @Test void warehouseEvidenceUsesRunDimensionsAndOnlyDeclaredStageTypesAreWritten() { + var warehouse=new LingxingAnalyticsClient.Warehouse("A","Warehouse",0,0,"America/New_York"); + when(client.warehouses(any(SelfopRequestContext.class),eq(1),eq(10))).thenReturn(page(1,11,2,List.of(warehouse))); + runner.run(task,run,lease); + var dimensions=read(run.getObservedDimensionsJson(),Map.class); + assertEquals(true,dimensions.get("truncated"));assertTrue(dimensions.containsKey("validationWarehouseSamples")); + verify(stages,never()).insert(any(SelfopSyncStage.class));assertEquals("SAMPLE_DONE",run.getStatus()); + } + @Test void overlongSkuCannotBeAcceptedAsStructurallyValidStock() { + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,1,1,List.of(stock("A","x".repeat(129),BigDecimal.ONE)))); + runner.run(task,run,lease);assertEquals("FAILED",run.getStatus()); + verify(egress).failValidation(1L,"VALIDATION_STRUCTURE_REJECTED");verify(stages,never()).insert(any(SelfopSyncStage.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java new file mode 100644 index 000000000..da250a304 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopOutboundDiscoveryScopeMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncScopeMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.server.ResponseStatusException; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SyncControllerTest { + private static final String SYNC = "platform:self-operated-analytics:sync"; + private final SelfopCommandService.Command command = new SelfopCommandService.Command( + "synthetic-command", "WH-A", "INVENTORY", null, null, null, null, null, "offline test"); + + @Test void anonymousAndCustomerCannotCreatePlatformTasksEvenWithSyncPermission() { + var commands = mock(SelfopCommandService.class); + var controller = new SyncController(commands); + assertForbidden(() -> controller.request(null, command)); + assertForbidden(() -> controller.request(user(100L, Set.of(SYNC)), command)); + verifyNoInteractions(commands); + } + + @Test void platformReadPermissionDoesNotAuthorizeManualSync() { + var commands = mock(SelfopCommandService.class); + var controller = new SyncController(commands); + assertForbidden(() -> controller.request(user(0L, Set.of("platform:self-operated-analytics:list")), command)); + assertForbidden(() -> controller.request(user(0L, Set.of()), command)); + verifyNoInteractions(commands); + } + + @Test void platformScopeFromClientAppAndImpersonatedUserAreDenied() { + var commands = mock(SelfopCommandService.class); + var controller = new SyncController(commands); + var client = new AuthenticatedUser(42L, 0L, 0L, 1L, "synthetic-user", "CLIENT", "WEB", Set.of(SYNC)); + var impersonated = new AuthenticatedUser(42L, 0L, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", Set.of(SYNC), + true, 77L, 2L, false); + assertForbidden(() -> controller.request(client, command)); + assertForbidden(() -> controller.request(impersonated, command)); + verifyNoInteractions(commands); + } + + @Test void authorizedPlatformCommandReturnsAcceptedWithoutPerformingCollection() { + var config = new SelfopAnalyticsProperties(); + assertThat(config.getCollectionMode()).isEqualTo("OFF"); + assertThat(config.isExecutionEnabled()).isFalse(); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSourceAccount("synthetic-account"); + config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse()); + var tasks = mock(SelfopTaskService.class); + var scopes = mock(SelfopSyncScopeMapper.class); + var accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + var scope = new SelfopSyncScope(); + scope.setId(10L); scope.setScopeHash("synthetic-scope-hash"); + scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\",\"qualities\":\"ALL\"}"); + when(scopes.selectOne(any())).thenReturn(scope); + when(tasks.create(any(SelfopSyncTask.class))).thenAnswer(call -> { + SelfopSyncTask task = call.getArgument(0); task.setId(101L); task.setTaskNo("LXCOL-20261012-00000101"); return task; + }); + var taskRows = mock(SelfopSyncTaskMapper.class); + var commands = new SelfopCommandService(tasks, scopes, accounts, config, taskRows); + var response = new SyncController(commands).request(user(0L, Set.of(SYNC)), command); + assertThat(response.successful()).isTrue(); + assertThat(response.data().taskNo()).isEqualTo("LXCOL-20261012-00000101"); + assertThat(response.data().status()).isEqualTo("ACCEPTED"); + assertThat(response.data().warehouseCode()).isEqualTo("WH-A"); + assertThat(config.isExecutionEnabled()).isTrue(); + assertThat(config.isSalesPublicationEnabled()).isFalse(); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(tasks).create(captured.capture()); + assertThat(captured.getValue().getRequestedBy()).isEqualTo("42"); + assertThat(captured.getValue().getRequestKey()).isEqualTo("manual:synthetic-command"); + assertThat(captured.getValue().getTaskRole()).isEqualTo("WAREHOUSE_SYNC"); + assertThat(captured.getValue().getWarehouseCode()).isEqualTo("WH-A"); + verify(tasks).initialize(); + verifyNoInteractions(accounts, taskRows); + } + + @Test void endpointDeclaresIndependentSyncAuthority() throws Exception { + var method = SyncController.class.getMethod("request", AuthenticatedUser.class, SelfopCommandService.Command.class); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('" + SYNC + "')"); + } + + private AuthenticatedUser user(Long customerScope, Set permissions) { + return new AuthenticatedUser(42L, customerScope, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", permissions); + } + private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable request) { + assertThatThrownBy(request).isInstanceOf(ResponseStatusException.class) + .satisfies(e -> assertThat(((ResponseStatusException)e).getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java index c56af968d..fbabbeccd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java @@ -1,7 +1,9 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.lastleg.service.reference.ReturnCustomerReferenceClient; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import org.junit.jupiter.api.Test; import java.util.List; @@ -11,11 +13,11 @@ import static org.mockito.Mockito.*; class ReturnOrderReferenceResolverTest { @Test void directoryFailureBlocksNewSnapshotButDoesNotHideHistoricalOrders() { - var customers = mock(FleetCustomerDirectory.class); + var customers = mock(ReturnCustomerReferenceClient.class); var warehouses = mock(WarehouseResourceGateway.class); - when(customers.find("1")).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE)); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE)); when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE")); - var resolver = new ReturnOrderReferenceResolver(customers, warehouses); + var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token"); org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> resolver.populate(List.of(order()))); var historical = order(); resolver.populateForRead(List.of(historical)); @@ -24,24 +26,55 @@ class ReturnOrderReferenceResolverTest { @Test void resolvesMissingReturnReferencesOncePerCustomerAndWarehouseAndPreservesSnapshots() { - var customers = mock(FleetCustomerDirectory.class); + var customers = mock(ReturnCustomerReferenceClient.class); var warehouses = mock(WarehouseResourceGateway.class); - when(customers.find("1")).thenReturn(new FleetCustomerDirectory.Customer("1", "C1", "测试货主", null, null)); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenReturn(ApiResponse.success(new PageResult<>( + List.of(new ReturnCustomerReferenceClient.Customer(1L, "C1", "测试货主", "ACTIVE", null, null)), 1, 30, 1, 1))); when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE")); LastlegOrder first = order(), second = order(), frozen = order(); frozen.setCustomerName("原货主快照"); frozen.setOverseasWarehouseCode("OLD"); frozen.setOverseasWarehouseName("原仓库快照"); - new ReturnOrderReferenceResolver(customers, warehouses).populate(List.of(first, second, frozen)); + new ReturnOrderReferenceResolver(customers, warehouses, "test-token").populate(List.of(first, second, frozen)); assertEquals("测试货主", first.getCustomerName()); assertEquals("USLAX01", second.getOverseasWarehouseCode()); assertEquals("洛杉矶仓", second.getOverseasWarehouseName()); assertEquals("原货主快照", frozen.getCustomerName()); assertEquals("原仓库快照", frozen.getOverseasWarehouseName()); - verify(customers, times(1)).find("1"); + verify(customers, times(1)).customers("test-token", 1, 30, null, "ACTIVE", 1L); verify(warehouses, times(1)).requireActiveSharedWarehouse(10L); } + @Test + void foreignOrInactiveCustomerCannotSupplyReturnSnapshot() { + var customers = mock(ReturnCustomerReferenceClient.class); + var warehouses = mock(WarehouseResourceGateway.class); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenReturn(ApiResponse.success(new PageResult<>(List.of( + new ReturnCustomerReferenceClient.Customer(2L, "C2", "其他客户", "ACTIVE", null, null), + new ReturnCustomerReferenceClient.Customer(1L, "C1", "停用客户", "DISABLED", null, null)), 1, 30, 2, 1))); + var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token"); + var error = org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> resolver.populate(List.of(order()))); + assertEquals(400, error.getStatusCode().value()); + verifyNoInteractions(warehouses); + } + + @Test + void unexpectedClientFailureDoesNotHideHistoricalReturnAndKeepsMissingNameUnknown() { + var customers = mock(ReturnCustomerReferenceClient.class); + var warehouses = mock(WarehouseResourceGateway.class); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenThrow(new IllegalStateException("transport failure")); + when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE")); + var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token"); + var historical = order(); + resolver.populateForRead(List.of(historical)); + org.junit.jupiter.api.Assertions.assertNull(historical.getCustomerName()); + assertEquals("洛杉矶仓", historical.getOverseasWarehouseName()); + var error = org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> resolver.populate(List.of(order()))); + assertEquals(503, error.getStatusCode().value()); + } + private LastlegOrder order() { var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL"); order.setCustomerId("1"); order.setOverseasWarehouseId("10"); return order; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java index b8876ad70..839759ec8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java @@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; -import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.baiyue.lastleg.transfer.gateway.TransferWarehouseCatalogClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.entity.*; @@ -35,7 +35,7 @@ import static org.mockito.Mockito.*; class WarehouseTransferReceiptCoordinatorTest { JdbcTemplate jdbc; SqlSessionTemplate session; WarehouseTransferReceiptCoordinator coordinator; - WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; FleetWarehouseClient catalog; + WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; TransferWarehouseCatalogClient catalog; ObjectMapper json = new ObjectMapper().findAndRegisterModules(); AtomicInteger occupancies = new AtomicInteger(), reserveCalls = new AtomicInteger(), stockCalls = new AtomicInteger(); AtomicBoolean loseReserveResponse = new AtomicBoolean(), failLocal = new AtomicBoolean(); @@ -73,7 +73,7 @@ class WarehouseTransferReceiptCoordinatorTest { ReflectionTestUtils.setField(coordinator, "commands", session.getMapper(WarehouseTransferReceiptCommandMapper.class)); ReflectionTestUtils.setField(coordinator, "transactionManager", new DataSourceTransactionManager(ds)); ReflectionTestUtils.setField(coordinator, "objectMapper", json); - warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(FleetWarehouseClient.class); + warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(TransferWarehouseCatalogClient.class); ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "directory", directory); ReflectionTestUtils.setField(coordinator, "catalog", catalog); ReflectionTestUtils.setField(coordinator, "internalToken", "test-token"); when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE")); @@ -81,7 +81,7 @@ class WarehouseTransferReceiptCoordinatorTest { when(warehouse.resolveLocation(eq(9L), anyLong())).thenAnswer(call -> new WarehouseResourceGateway.WarehouseLocationReference( call.getArgument(1), "LOC-" + call.getArgument(1), "STORAGE", new BigDecimal("1000"), "ACTIVE")); when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of( - new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US")))); + new TransferWarehouseCatalogClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US")))); when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail( new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE", null, null), List.of()))); when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> { diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 295791f22..53a2a0df1 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -23,7 +23,6 @@ "inventory->inbound-pda", "inventory->shared-technical", "overseas-master-data->shared-technical", - "procurement-trucking-looseboard->overseas-master-data", "procurement-trucking-looseboard->shared-technical", "procurement-trucking-looseboard->tailleg-handoff", "relabel-transfer-discard->dispatch-delivery", @@ -55,6 +54,81 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "退货换标合箱沿用PDA冻结仓身份作为操作人与仓库约束,不引用入仓业务服务或持久化。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferReceiptCoordinator", + "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", + "reason": "转仓实收复用仓储资源HTTP网关,校验及预留目标托盘库位;资源权威仍在Warehouse,不读取入仓持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteModels", + "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteService", + "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsModels", + "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsService", + "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests", + "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.inventory.query.service.InventoryQueryService", + "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos", + "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService", + "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests", + "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService", + "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels", + "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService", + "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" + }, + { + "source": "com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressController", + "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", + "reason": "内部出口许可端点复用现行服务令牌校验,不复制认证实现;只允许该控制器依赖该基础校验器,不开放客户业务或共享技术切片的其他引用。" + }, { "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway", @@ -214,7 +288,7 @@ }, "overseas-master-data": { "allowedOutgoing": ["shared-technical"], - "allowedIncoming": ["procurement-trucking-looseboard", "fleet"] + "allowedIncoming": ["fleet"] } } } From d782b2cd24d2e3854ff3f8ec61bc50838b84eaec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 20:45:31 +0800 Subject: [PATCH 11/80] fix: reject unavailable self-operated collection commands before initialization --- .../analytics/sync/SelfopCommandService.java | 51 ++++++++++- .../analytics/sync/SyncController.java | 2 +- .../sync/SelfopCommandServiceTest.java | 13 +-- .../analytics/sync/SyncControllerTest.java | 84 +++++++++++++++++++ 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java index 6023e7365..87a5540f5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.selfoperated.analytics.sync; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.selfoperated.analytics.mapper.*; import com.baiyue.lastleg.selfoperated.analytics.model.*; import org.springframework.stereotype.Service; @@ -23,18 +24,39 @@ public class SelfopCommandService { } @Transactional public SelfopSyncTask request(Command c,String operator){ - identity(c.requestKey(),100);identity(c.warehouseCode(),64);require(config.getWarehouses().containsKey(c.warehouseCode()),"SELFOP_WAREHOUSE_UNCONFIGURED"); - taskService.initialize(); - if("RETRY".equals(c.action())){identity(c.taskNo(),64);identity(c.reason(),500); + try { return requestChecked(c,operator); } + catch(IllegalStateException failure){ + String message=failure.getMessage()==null?null:REJECTIONS.get(failure.getMessage()); + if(message==null)throw failure; + throw new BizException(message); + } + } + private SelfopSyncTask requestChecked(Command c,String operator){ + if(!config.isExecutionEnabled())throw new BizException("采集功能未启用,当前环境不允许执行采集或重试"); + if(c==null)throw new BizException("请提供采集命令"); + input(c.requestKey(),100,"请求标识");input(c.warehouseCode(),64,"仓库编码"); + require(config.getWarehouses().containsKey(c.warehouseCode()),"SELFOP_WAREHOUSE_UNCONFIGURED"); + if(c.action()==null||!Set.of("RETRY","INVENTORY","BACKFILL","RECHECK","REBUILD","VALIDATION").contains(c.action())) + throw new BizException("不支持此采集操作"); + if("RETRY".equals(c.action())){input(c.taskNo(),64,"任务编号");input(c.reason(),500,"重试原因"); var original=tasks.selectOne(new QueryWrapper().eq("task_no",c.taskNo()).eq("source_account",config.getSourceAccount()));require(original!=null,"SELFOP_TASK_SCOPE_MISMATCH"); require("WAREHOUSE_SYNC".equals(original.getTaskRole())&&c.warehouseCode().equals(original.getWarehouseCode()),"SELFOP_TASK_SCOPE_MISMATCH"); require(!"WAITING_SOURCE".equals(original.getStatus()),"SELFOP_RETRY_WAITING_SOURCE"); + require("BUSINESS".equals(original.getExecutionPurpose())&&"BUSINESS".equals(config.getCollectionMode()),"SELFOP_BUSINESS_PURPOSE_REQUIRED"); return taskService.retry(original.getId(),c.requestKey(),operator,c.reason());} - require(Set.of("INVENTORY","BACKFILL","RECHECK","REBUILD","VALIDATION").contains(c.action()),"SELFOP_COMMAND_INVALID"); String purpose=SelfopCollectionPolicy.configuredPurpose(config); require(("VALIDATION".equals(purpose))==("VALIDATION".equals(c.action())),"SELFOP_VALIDATION_ENTRY_REQUIRED"); + if("RECHECK".equals(c.action())){input(c.customerCode(),64,"货主编码");input(c.deliveryNo(),128,"出库单号");} + if(Set.of("BACKFILL","VALIDATION").contains(c.action())){ + require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil()),"SELFOP_QUERY_WINDOW_INVALID"); + require(!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_SLICE_MAX_ONE_DAY"); + if(config.getSourceQueryTimezone()!=null){var source=ZoneId.of(config.getSourceQueryTimezone()); + require(source.getRules().getValidOffsets(c.sourceFrom()).size()==1&&source.getRules().getValidOffsets(c.sourceUntil()).size()==1,"SELFOP_SOURCE_TIME_AMBIGUOUS");} + } + taskService.initialize(); var scope=scopes.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("warehouse_code",c.warehouseCode()) .eq("data_family","INVENTORY".equals(c.action())?"INVENTORY":"OUTBOUND")); + require(scope!=null,"SELFOP_SCOPE_MISSING"); var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setRequestedBy(operator);task.setRequestKey("manual:"+c.requestKey()); task.setExecutionPurpose(purpose); task.setTaskType("INVENTORY".equals(c.action())?"BACKFILL":c.action());task.setScopeHash(scope.getScopeHash()); @@ -42,6 +64,7 @@ public class SelfopCommandService { task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(scope.getId());task.setWarehouseCode(c.warehouseCode());task.setQueryScopeJson(scope.getScopeJson()); }else{ var account=accounts.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount())); + require(account!=null,"SELFOP_SCOPE_MISSING"); task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(account.getId()); if("VALIDATION".equals(c.action())){ require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil())&&!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_QUERY_WINDOW_INVALID"); @@ -62,4 +85,24 @@ public class SelfopCommandService { } task.setRequestHash(hash(Arrays.asList(c,task.getScopeHash())));return taskService.create(task); } + private static void input(String value,int max,String label){ + if(value==null||value.isBlank()||value.length()>max)throw new BizException(label+"不能为空且不能超过"+max+"个字符"); + } + private static final Map REJECTIONS=Map.ofEntries( + Map.entry("SELFOP_WAREHOUSE_UNCONFIGURED","该仓库尚未配置采集,暂不能提交任务"), + Map.entry("SELFOP_TASK_SCOPE_MISMATCH","任务不存在或不属于当前账号和仓库"), + Map.entry("SELFOP_TASK_MISSING","任务不存在,请刷新后重试"), + Map.entry("SELFOP_RETRY_WAITING_SOURCE","任务正在等待来源数据,暂不能重试"), + Map.entry("SELFOP_BUSINESS_PURPOSE_REQUIRED","验证任务不能通过正式采集重试恢复"), + Map.entry("SELFOP_VALIDATION_ENTRY_REQUIRED","当前采集模式与所选操作不一致,请使用对应入口"), + Map.entry("SELFOP_QUERY_WINDOW_INVALID","采集时间范围不能为空,结束时间必须晚于开始时间"), + Map.entry("SELFOP_SLICE_MAX_ONE_DAY","单次采集时间范围不能超过一天"), + Map.entry("SELFOP_SOURCE_TIME_AMBIGUOUS","来源时间存在时区歧义,请重新选择时间范围"), + Map.entry("SELFOP_SCOPE_MISSING","采集作用域尚未准备就绪,请联系管理员"), + Map.entry("SELFOP_IDEMPOTENCY_CONFLICT","请求标识已用于其他内容,请刷新后重新提交"), + Map.entry("SELFOP_RETRY_LEASE_BUSY","任务正在执行,暂不能重试"), + Map.entry("SELFOP_TASK_ALREADY_SUCCEEDED","任务已成功完成,无需重试"), + Map.entry("SELFOP_COLLECTION_MODE_REJECTED","当前采集模式不允许此操作"), + Map.entry("SELFOP_BUSINESS_ENVIRONMENT_REJECTED","当前环境不允许正式采集"), + Map.entry("SELFOP_BUDGET_INVALID","采集预算配置无效,请联系管理员")); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java index f2f8b21cb..88e784a59 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java @@ -18,7 +18,7 @@ public class SyncController { @PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')") @Operation(summary="创建自营分析人工采集或恢复任务",description="平台独立写权限 platform:self-operated-analytics:sync;持久化后异步执行,执行开关默认关闭。BACKFILL使用不超过一天的来源时间切片;未知源时区仅允许暂存。") public ApiResponse request(@AuthenticationPrincipal AuthenticatedUser user,@RequestBody SelfopCommandService.Command command){ - if(user==null||user.tenantId()==null||user.tenantId()!=0||!"PLATFORM".equals(user.appCode())||user.impersonated()||!user.permissions().contains("platform:self-operated-analytics:sync")) + if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0||!"PLATFORM".equals(user.appCode())||user.impersonated()||!user.permissions().contains("platform:self-operated-analytics:sync")) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅限已授权平台人员"); var task=commands.request(command,String.valueOf(user.userId()));return ApiResponse.success(new Accepted(task.getTaskNo(),command.warehouseCode(),"ACCEPTED")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java index 58877357d..d749e84d4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java @@ -26,7 +26,7 @@ class SelfopCommandServiceTest { configure(); var original = original("synthetic-account", "WH-B"); when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; }); - assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH"); + assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("任务不存在或不属于当前账号和仓库"); verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString()); verify(taskService, never()).create(any(SelfopSyncTask.class)); assertThat(original.getStatus()).isEqualTo("DEAD"); @@ -36,7 +36,7 @@ class SelfopCommandServiceTest { configure(); var original = original("different-source-account", "WH-A"); when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; }); - assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH"); + assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("任务不存在或不属于当前账号和仓库"); verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString()); verify(taskService, never()).create(any(SelfopSyncTask.class)); assertThat(original.getStatus()).isEqualTo("DEAD"); @@ -77,16 +77,17 @@ class SelfopCommandServiceTest { configure();config.setCollectionMode("OFF"); assertThat(config.isExecutionEnabled()).isFalse(); var c=new SelfopCommandService.Command("disabled","WH-A","INVENTORY",null,null,null,null,null,"offline test"); - assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_PURPOSE_INVALID"); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试"); + assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试"); config.setCollectionMode("BUSINESS");config.setEnvironment("test"); assertThat(config.isExecutionEnabled()).isFalse(); - assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_BUSINESS_ENVIRONMENT_REJECTED"); - verify(taskService,never()).create(any(SelfopSyncTask.class)); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试"); + verifyNoInteractions(taskService,scopes,accounts,tasks); } @Test void validationModeRejectsFormalInventoryEntry() { configure();config.setCollectionMode("VALIDATION"); var c=new SelfopCommandService.Command("wrong-purpose","WH-A","INVENTORY",null,null,null,null,null,"offline test"); - assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_VALIDATION_ENTRY_REQUIRED"); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("当前采集模式与所选操作不一致,请使用对应入口"); verify(taskService,never()).create(any(SelfopSyncTask.class)); } private SelfopCommandService.Command retry() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java index da250a304..5e23901c0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java @@ -13,6 +13,17 @@ import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.server.ResponseStatusException; import java.util.Set; +import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.core.MethodParameter; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.bind.support.WebDataBinderFactory; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -88,6 +99,79 @@ class SyncControllerTest { assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('" + SYNC + "')"); } + @Test void offAndRetryAreChineseBusinessRejectionsBeforeAnyInitialization() throws Exception { + var config=new SelfopAnalyticsProperties(); + var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class); + var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class); + var mvc=mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows)); + rejected(mvc,command,"采集功能未启用,当前环境不允许执行采集或重试"); + rejected(mvc,new SelfopCommandService.Command("retry","WH-A","RETRY","LXCOL-1",null,null,null,null,"原因"), + "采集功能未启用,当前环境不允许执行采集或重试"); + verifyNoInteractions(tasks,scopes,accounts,rows); + } + + @Test void unconfiguredWarehouseAndInvalidPublicInputsRejectBeforeInitialization() throws Exception { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production"); + var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class); + var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class); + var mvc=mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows)); + rejected(mvc,command,"该仓库尚未配置采集,暂不能提交任务"); + config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + rejected(mvc,new SelfopCommandService.Command("invalid","WH-A",null,null,null,null,null,null,null),"不支持此采集操作"); + rejected(mvc,new SelfopCommandService.Command("invalid","WH-A","BACKFILL",null,null,null,null,null,null), + "采集时间范围不能为空,结束时间必须晚于开始时间"); + rejected(mvc,new SelfopCommandService.Command("invalid","WH-A","RETRY","LXCOL-1",null,null,null,null," "), + "重试原因不能为空且不能超过500个字符"); + verifyNoInteractions(tasks,scopes,accounts,rows); + } + + @Test void controlledTaskConflictIsBusinessRejectionButUnknownFailureRemainsSystemError() throws Exception { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production"); + config.setSourceAccount("synthetic-account");config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class); + var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class); + var scope=new SelfopSyncScope();scope.setId(10L);scope.setScopeHash("hash");when(scopes.selectOne(any())).thenReturn(scope); + var mvc=mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows)); + doThrow(new IllegalStateException("SELFOP_IDEMPOTENCY_CONFLICT")).when(tasks).create(any()); + rejected(mvc,command,"请求标识已用于其他内容,请刷新后重新提交"); + doThrow(new IllegalStateException("unknown runtime failure")).when(tasks).create(any()); + mvc.perform(post("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks") + .contentType("application/json").content(SelfopSupport.json(command))) + // The platform handler transports all result codes in HTTP 200; unknown failures retain S0500. + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0500")); + } + + @Test void runningRetryIsControlledAndDoesNotInitializeScopes() throws Exception { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production"); + config.setSourceAccount("synthetic-account");config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class); + var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class); + var original=new SelfopSyncTask();original.setId(1L);original.setTaskRole("WAREHOUSE_SYNC"); + original.setWarehouseCode("WH-A");original.setExecutionPurpose("BUSINESS");original.setStatus("FETCHING"); + when(rows.selectOne(any())).thenReturn(original); + doThrow(new IllegalStateException("SELFOP_RETRY_LEASE_BUSY")).when(tasks).retry(1L,"retry","42","原因"); + rejected(mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows)), + new SelfopCommandService.Command("retry","WH-A","RETRY","LXCOL-1",null,null,null,null,"原因"), + "任务正在执行,暂不能重试"); + verify(tasks,never()).initialize();verify(tasks,never()).create(any());verifyNoInteractions(scopes,accounts); + } + + private void rejected(MockMvc mvc,SelfopCommandService.Command request,String message) throws Exception { + mvc.perform(post("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks") + .contentType("application/json").content(SelfopSupport.json(request))) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001")) + .andExpect(jsonPath("$.message").value(message)); + } + private MockMvc mvc(SelfopCommandService commands){ + return MockMvcBuilders.standaloneSetup(new SyncController(commands)).setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver(){ + public boolean supportsParameter(MethodParameter parameter){return parameter.getParameterType()==AuthenticatedUser.class;} + public Object resolveArgument(MethodParameter parameter,ModelAndViewContainer container,NativeWebRequest request,WebDataBinderFactory factory){ + return user(0L,Set.of(SYNC)); + } + }).build(); + } + private AuthenticatedUser user(Long customerScope, Set permissions) { return new AuthenticatedUser(42L, customerScope, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", permissions); } From d4dc4f875aedce883718a91b9f955f8131ad2440 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 20:58:16 +0800 Subject: [PATCH 12/80] fix: register current synchronous service dependencies and guard callback boundaries --- scripts/synchronous-dependency-baseline.mjs | 24 ++++++ .../synchronous-dependency-contract.test.mjs | 76 +++++++++++++++---- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index dbfbb24ec..1dda4fdbf 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,30 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java', + 'lastleg-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', + '预约影响刷新按柜次批量读取头程不可变船期事实;POST仅查询,刷新写入仅在Lastleg本域,不写Firstleg'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '预约货源、整柜及外接候选读取权威启用仓库目录与主档,不创建仓库或预留资源'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java', + 'lastleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', + '退货订单补全客户快照时按客户ID读取IAM有效客户;历史读取失败保留未知,不调用车队业务或客户资格写操作'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '转仓实收按冻结FBA编码读取权威目录,拒绝缺失或歧义;资源预留仍由独立WarehouseResourceGateway处理'), + feign('order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java', + 'order-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ', + '客户订单工作台按服务端客户ID批量读取尾程进度;POST无业务写入,失败不伪造待办或回退成功'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java', + 'lastleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'NONE', + '持久任务后台逐页/逐单读取领星适配接口;每次调用先预留账号额度并冻结attempt上下文,真实尝试耗费预算,未知结果持久暂停,禁止传输层自动重试;人工命令仅入队,报表查询不调用此客户端'), + http('integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java', + 'integration-service', 'lastleg-service', ['BACKGROUND'], 'READ_WRITE', 'MIXED', + '后台采集出口向Lastleg申请一次性许可并确认真实尝试;authorize消费持久预算,不可重复获得许可,complete按attempt/operation/hash及结果摘要幂等,冲突拒绝;不递归执行采集,不自动重试或跟随重定向'), + http('integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java', + 'integration-service', 'external-lingxing-wms', ['EXTERNAL'], 'READ', 'NONE', + '受控WMS OMP查询不写供应商业务,但每次HTTP消耗账号额度;一许可仅允许一次尝试,OFF/无独占窗口拒绝,429或结果不确定停止新请求,无自动重试,不等同可无成本重放的SAFE_READ'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java', 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', '车次财务按不可变发车客户及派送来源批量只读查询,POST无业务写入;内部服务令牌保护,实收另按公网权限裁剪,不逐行查询或推算未分配收款'), diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 94cdf6b22..6675777cd 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -33,6 +33,7 @@ const expectedOnlineEdges = [ 'iam-service->warehouse-service', 'lastleg-service->file-service', 'lastleg-service->finance-service', + 'lastleg-service->firstleg-service', 'lastleg-service->iam-service', 'lastleg-service->print-service', 'lastleg-service->warehouse-overseas-service', @@ -41,12 +42,15 @@ const expectedOnlineEdges = [ 'order-service->finance-service', 'order-service->firstleg-service', 'order-service->iam-service', + 'order-service->lastleg-service', 'order-service->warehouse-service', 'warehouse-overseas-service->print-service', 'warehouse-overseas-service->warehouse-service' ] const expectedBackgroundEdges = [ + 'integration-service->lastleg-service', + 'lastleg-service->integration-service', 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', @@ -103,7 +107,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata', const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 72) + assert.equal(reviewed.length, 77) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -119,7 +123,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 25) + assert.equal(reviewed.length, 28) assert.deepEqual(discovered.sort(), reviewed) }) @@ -194,14 +198,14 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ ['auth-service', 'iam-service'], - ['finance-service', 'firstleg-service', 'order-service'] + ['finance-service', 'firstleg-service', 'lastleg-service', 'order-service'] ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ - 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' + 'finance-service', 'firstleg-service', 'integration-service', 'lastleg-service', 'order-service' ]]) assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ 'auth-service', 'file-service', 'finance-service', - 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' + 'firstleg-service', 'iam-service', 'integration-service', 'lastleg-service', 'order-service' ]]) }) @@ -222,7 +226,7 @@ test('unified authority checks remain infrastructure edges and direct events rem assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) }) -test('Order, Firstleg and Finance form the reviewed REQUEST SCC', () => { +test('Order, Firstleg, Finance and Lastleg form the reviewed REQUEST SCC', () => { const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST')) const orderToFirstleg = requestClients.filter(client => client.source === 'order-service' && client.target === 'firstleg-service') @@ -260,17 +264,17 @@ test('known user entries expose every longest simple REQUEST path over the two-h 'iam-service->auth-service', 'iam-service->warehouse-service' ] }, - 'order-service': { hops: 5, paths: [ - 'order-service->finance-service->firstleg-service->file-service->iam-service->auth-service', - 'order-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service' + 'order-service': { hops: 6, paths: [ + 'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->auth-service', + 'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service' ] }, - 'firstleg-service': { hops: 5, paths: [ - 'firstleg-service->order-service->finance-service->file-service->iam-service->auth-service', - 'firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service' + 'firstleg-service': { hops: 6, paths: [ + 'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->auth-service', + 'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->warehouse-service' ] }, - 'finance-service': { hops: 5, paths: [ - 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', - 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service' + 'finance-service': { hops: 6, paths: [ + 'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->auth-service', + 'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->warehouse-service' ] }, 'file-service': { hops: 2, paths: [ 'file-service->iam-service->auth-service', @@ -278,7 +282,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h ] }, 'lastleg-service': { hops: 6, paths: [ 'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', - 'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service' + 'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service', + 'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->auth-service', + 'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service' ] }, 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, 'warehouse-overseas-service': { hops: 1, paths: [ @@ -292,6 +298,44 @@ test('known user entries expose every longest simple REQUEST path over the two-h } }) +test('self-operated collection and quota callbacks stay outside the REQUEST graph', async () => { + const find = suffix => SYNCHRONOUS_CLIENTS.find(client => client.path.endsWith(suffix)) + const collection = find('/selfoperated/analytics/client/RestLingxingAnalyticsClient.java') + const authority = find('/provider/lingxing/LingxingHttpBudgetAuthority.java') + const supplier = find('/provider/lingxing/LingxingWmsClient.java') + assert.deepEqual([collection.contexts, collection.access, collection.idempotency], [['BACKGROUND'], 'READ', 'NONE']) + assert.deepEqual([authority.contexts, authority.access, authority.idempotency], [['BACKGROUND'], 'READ_WRITE', 'MIXED']) + assert.deepEqual([supplier.contexts, supplier.access, supplier.idempotency], [['EXTERNAL'], 'READ', 'NONE']) + assert.equal(graphEdges('REQUEST').includes('lastleg-service->integration-service'), false) + assert.equal(graphEdges('REQUEST').includes('integration-service->lastleg-service'), false) + // The background service graph is bidirectional; the permit callback must not re-enter collection. + const egress = await readFile(path.join(root, + 'lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java'), 'utf8') + assert.doesNotMatch(egress, /RestLingxingAnalyticsClient|LingxingAnalyticsClient|SelfopExecutionService/) + const callback = await readFile(path.join(root, authority.path), 'utf8') + assert.match(callback, /followRedirects\(HttpClient\.Redirect\.NEVER\)/) + assert.match(callback, /timeout\(Duration\.ofSeconds\(5\)\)/) + assert.doesNotMatch(callback, /RetryTemplate|Retryer|@Retryable|\.retry\(/) +}) + +test('new domain reference clients are exact readonly internal dependencies', async () => { + const reviewed = [ + ['lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java', 'firstleg-service', '/internal/firstleg/appointment-forecasts/batch'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java', 'warehouse-service', '/internal/warehouses/order-catalog'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java', 'iam-service', '/internal/iam/finance-customers'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java', 'warehouse-service', '/internal/warehouses/order-catalog'], + ['order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java', 'lastleg-service', '/internal/lastleg/v1/customer-order-progress'] + ] + for (const [file, target, endpoint] of reviewed) { + const client = SYNCHRONOUS_CLIENTS.find(row => row.path === file) + assert.deepEqual([client.target, client.contexts, client.access, client.idempotency], [target, ['REQUEST'], 'READ', 'SAFE_READ']) + const source = await readFile(path.join(root, file), 'utf8') + assert.ok(source.includes(endpoint)) + assert.ok(source.includes('X-Internal-Service-Token')) + assert.doesNotMatch(source, /@(?:PutMapping|DeleteMapping|PatchMapping)/) + } +}) + test('delivery fee coverage is a single readonly Finance hop and cannot write financial state', async () => { const client = await readFile(path.join(root, 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java'), 'utf8') const service = await readFile(path.join(root, 'finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java'), 'utf8') From b702ed9f453537c09c5f949def000eee6e6ecc1f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 20:59:54 +0800 Subject: [PATCH 13/80] test: verify self-operated analytics through TEST Gateway and synthetic query fixtures --- .../selfoperated-test-env-acceptance.mjs | 308 ++++++++++++++++++ .../selfoperated-test-env-acceptance.test.mjs | 210 ++++++++++++ scripts/README.md | 2 + 3 files changed, 520 insertions(+) create mode 100644 lastleg-service/scripts/selfoperated-test-env-acceptance.mjs create mode 100644 lastleg-service/scripts/selfoperated-test-env-acceptance.test.mjs diff --git a/lastleg-service/scripts/selfoperated-test-env-acceptance.mjs b/lastleg-service/scripts/selfoperated-test-env-acceptance.mjs new file mode 100644 index 000000000..610dddf5d --- /dev/null +++ b/lastleg-service/scripts/selfoperated-test-env-acceptance.mjs @@ -0,0 +1,308 @@ +/** + * TEST-only Gateway acceptance. Default is a local plan; --execute performs login, + * read-only queries and, with --probe-off-command, one deliberately rejected INVENTORY + * command after verifying OFF. Never collects, publishes, creates fixtures or changes config. + * Credentials: node --env-file= BEFORE this script; this script does not read credential files. + * Required env: CHONGCHONG_ENV=TEST, CHONGCHONG_ADMIN_API_BASE_URL (ends /api), + * CHONGCHONG_ADMIN_USERNAME/PASSWORD, CHONGCHONG_MERCHANT_USERNAME/PASSWORD, + * SELFOP_READONLY_USERNAME/PASSWORD (read permission only), + * SELFOP_DENIED_USERNAME/PASSWORD (platform without read or sync permission). + * Example: node --env-file= ") List runs(@Param("f") Filter filter, @Param("b") Bound bound); - @Select("SELECT " + TASK + TASK_FROM + " WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} AND t.task_no=#{taskNo}") + @Select("SELECT " + TASK + TASK_FROM + " WHERE t.source_account=#{account} AND (" + TASK_WAREHOUSE + + ")=#{warehouse} AND " + TASK_MEMBERSHIP + " AND t.task_no=#{taskNo}") Task task(@Param("account") String account, @Param("warehouse") String warehouse, @Param("taskNo") String taskNo); @Select("SELECT api_cooldown_until_utc,api_pause_reason,api_budget_json FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} LIMIT 1") Protection protection(@Param("account") String account); @Select("") List tasksByIds(@Param("account") String account,@Param("ids") List ids); @Select("") List latestAttempts(@Param("account") String account,@Param("ids") List ids); @Select("") List publishedRuns(@Param("account") String account,@Param("ids") List ids); @@ -94,6 +116,13 @@ public interface CollectionReadMapper { + " WHERE scope_id IN #{id}) ranked" + " ON ranked.id=t.id AND ranked.rn=1 WHERE t.source_account=#{account}") List currentTasks(@Param("account") String account,@Param("ids") List ids); + @Select("") + List latestValidationTasks(@Param("account") String account,@Param("warehouses") List warehouses); @Select("") List tasksByIds(@Param("account") String account,@Param("ids") List ids); @Select(" + """) + int insertWarehouseDaily(@Param("rows") List rows); + + @Select("SELECT COALESCE(SUM(source_row_count),0) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=#{runId} AND source_account=#{account} AND warehouse_code=#{warehouse}") + long warehouseDailySourceRows(@Param("runId") Long runId,@Param("account") String account,@Param("warehouse") String warehouse); + + @Delete("DELETE FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=#{runId}") + int deleteWarehouseDaily(@Param("runId") Long runId); + @Insert(""" ") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java index 3194e89d6..fd0d1e492 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -34,7 +34,7 @@ public final class PalletOperationRequests { @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @Size(max = 500) String reason) { } - /** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */ + /** source为空时表示退货箱没有来源托盘,现场仅扫描箱唛后绑定目标托盘;actualSource为现场确认的原入仓库存行。 */ public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, @NotBlank @Size(max = 64) String relabelNo, @Valid PalletExpectation source, 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 aa85c1e4b..ae804f1b5 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 @@ -105,13 +105,16 @@ public class PdaPalletOperationService { || !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()) + String palletCode = text(carton.getPalletCode()); + PalletPhysicalGateway.PalletReference pallet = palletCode.isBlank() || "UNBOUND".equalsIgnoreCase(palletCode) + ? null : physical.resolve(palletCode); + if (palletCode.isBlank() || "UNBOUND".equalsIgnoreCase(palletCode)) palletCode = "UNBOUND"; + if (pallet != null && pallet.warehouseId() != identity.overseasWarehouseId()) throw conflict("退货换标来源托盘不属于当前仓库"); long quantity = entry.getValue().stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) ? value(carton.getTotalQuantity()) : 1; result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L, - pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity, + pallet == null ? null : pallet.palletId(), palletCode, null, carton.getSourceBatchNo(), quantity, quantity, entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList())); } return result; @@ -225,11 +228,15 @@ public class PdaPalletOperationService { if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } - Map pallets = resolvePallets(identity.overseasWarehouseId(), - request.source(), List.of(request.target())); - PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); - PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); - if (source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同"); + if (request.target() == null) throw badRequest("目标托盘不能为空"); + PalletPhysicalGateway.PalletReference target = resolvePallets(identity.overseasWarehouseId(), + List.of(request.target())).get(code(request.target().palletCode())); + PalletPhysicalGateway.PalletReference source = request.source() == null ? null + : resolvePallets(identity.overseasWarehouseId(), List.of(request.source())).get(code(request.source().palletCode())); + if (source != null && source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同"); + Map pallets = new LinkedHashMap<>(); + if (source != null) pallets.put(source.palletCode(), source); + pallets.put(target.palletCode(), target); List marks = request.cartonMarks().stream().map(String::trim).filter(value -> !value.isEmpty()).toList(); if (marks.size() != request.cartonMarks().size() || new HashSet<>(marks).size() != marks.size()) { throw badRequest("箱唛不能为空且不能重复"); @@ -245,6 +252,13 @@ public class PdaPalletOperationService { Map> itemsByPosition = new LinkedHashMap<>(); Map anchors = new LinkedHashMap<>(); boolean returnRelabel = selected.stream().allMatch(item -> item.getReturnCartonId() != null); + boolean unboundReturnRelabel = returnRelabel && selected.stream().allMatch(item -> { + ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId()); + return carton != null && (!org.springframework.util.StringUtils.hasText(carton.getPalletCode()) + || "UNBOUND".equalsIgnoreCase(carton.getPalletCode())); + }); + if (unboundReturnRelabel && source != null) throw conflict("无来源托盘的退货箱不能提交来源托盘"); + if (!unboundReturnRelabel && source == null) throw conflict("请先确认来源托盘"); InventoryPosition actual = returnRelabel || request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId()); if (!returnRelabel && request.actualSource() != null && (actual == null @@ -269,7 +283,7 @@ public class PdaPalletOperationService { } } if (returnRelabel) { - return bindReturnRelabelPallet(identity, application, selected, source, target, request); + return bindReturnRelabelPallet(identity, application, selected, source, target, request, unboundReturnRelabel); } for (RelabelItem item : selected) { if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null @@ -313,7 +327,7 @@ public class PdaPalletOperationService { private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity, RelabelApplication application, List selected, PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target, - PalletOperationRequests.RelabelDisassemble request) { + PalletOperationRequests.RelabelDisassemble request, boolean unboundSource) { requireRelabelDestination(identity.overseasWarehouseId(), selected, target); Set alreadyBound = boundRelabelMarks(application); boolean rebind = request.actualSource() == null && selected.stream().allMatch(item -> alreadyBound.contains(item.getCartonMark())); @@ -324,14 +338,15 @@ public class PdaPalletOperationService { 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()) + || (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode())) + : !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 (!rebind && (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId() + if (!rebind && !unboundSource && (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId() || request.actualSource().packages() != quantity)) throw conflict("退货换标拆出数量必须等于本批已完成贴标数量"); String key = required(request.idempotencyKey(), "幂等键不能为空"); @@ -345,7 +360,8 @@ public class PdaPalletOperationService { 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()) + || (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode())) + : !Objects.equals(carton.getPalletCode(), source.palletCode())) || !Objects.equals(carton.getRelabelApplicationId(), application.getId()) || !"RELABEL_PENDING".equals(carton.getStatus())) throw conflict("退货换标来源库存已变化,请刷新后重试"); @@ -363,9 +379,10 @@ public class PdaPalletOperationService { } } recordReturnRelabelBinding(identity, application, selected, target.palletCode()); - List contents = List.of( - content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())), - content(identity.overseasWarehouseId(), physical.resolve(target.palletCode()))); + List contents = source == null + ? List.of(content(identity.overseasWarehouseId(), physical.resolve(target.palletCode()))) + : 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)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 47391ee83..7854f98b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -19,7 +19,8 @@ public final class RelabelResponses { String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl, String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, String reviewChannel, String reviewRemark, LocalDateTime createdAt, - LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt) {} + LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, + String productNameZh, String labelRequirement) {} public record Milestones(LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, 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 37e764e0c..6e72bba6d 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 @@ -1187,7 +1187,11 @@ public class RelabelApplicationService implements WalletResultHandler { private RelabelResponses.Summary summary(RelabelApplication value, List detailItems) { LastlegOrder order = orders.selectById(value.getLastlegOrderId()); RelabelResponses.Milestones milestones = applications.findMilestones(value.getCustomerId(), value.getId()); - String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(",")); + String skuCodes = detailItems.stream().flatMap(item -> { + List skuItems = readSkuItems(item.getSkuRelabelItemsJson()); + return skuItems.isEmpty() ? java.util.Arrays.stream(Objects.requireNonNullElse(item.getSkuCode(), "").split(",")) + : skuItems.stream().map(RelabelResponses.SkuItem::skuCode); + }).filter(StringUtils::hasText).map(String::trim).distinct().collect(Collectors.joining(",")); return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(), order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(), @@ -1206,7 +1210,11 @@ public class RelabelApplicationService implements WalletResultHandler { null, value.getCreatedAt(), milestones == null ? null : milestones.reviewedAt(), milestones == null ? null : milestones.relabelCompletedAt(), - milestones == null ? null : milestones.outboundAt()); + milestones == null ? null : milestones.outboundAt(), + detailItems.stream().map(RelabelItem::getProductNameZh).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(Collectors.joining("、")), + detailItems.stream().map(RelabelItem::getLabelRequirement).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(Collectors.joining(";"))); } @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java index 7f95a645e..50b0809f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java @@ -27,13 +27,13 @@ public interface ReturnInventoryBalanceMapper extends BaseMapperra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id)))))"; String ALL_COMPLETION_DEDUCTION = "COALESCE((SELECT SUM(j.quantity) FROM ll_relabel_pda_history h " + "JOIN JSON_TABLE(CASE WHEN h.action='RETURN_COMPLETION_TRANSFER' AND JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END,'$.items[*]' COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode',quantity BIGINT PATH '$.quantity')) j " + diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 0a9e32e30..8d5571c86 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3064,8 +3064,11 @@ components: labelDeadline: { type: string, format: date-time } status: { $ref: '#/components/schemas/RelabelStatus' } itemCount: { type: integer } + skuCodes: { type: string, description: 汇总全部箱内 skuItems 的旧 SKU 并去重,以逗号分隔;无 SKU 换标子明细时汇总主明细 SKU } totalPackageCount: { type: integer } notifiedPackageCount: { type: integer } + productNameZh: { type: string, description: 明细非空中文品名按原顺序去重并以顿号连接 } + labelRequirement: { type: string, description: 明细非空贴标要求按原顺序去重并以中文分号连接 } deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] } printShippingLabel: { type: boolean } shippingLabelFileId: { type: string, nullable: true } 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 c0a365f33..20608ec9d 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 @@ -74,11 +74,20 @@ class RelabelApplicationServiceTest { var outbound = reviewed.plusHours(2); when(applications.findMilestones("10001", 42L)) .thenReturn(new RelabelResponses.Milestones(reviewed, completed, outbound)); - RelabelResponses.Summary result = ReflectionTestUtils.invokeMethod(service, "summary", application, List.of()); + var first = new RelabelItem(); first.setProductNameZh(" 商品甲 "); first.setLabelRequirement(" 贴标要求111 "); + first.setSkuCode("SKU-FIRST"); + first.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU-FIRST\",\"newSkuCode\":\"NEW-FIRST\",\"quantity\":10},{\"skuCode\":\"SKU-SECOND\",\"newSkuCode\":\"NEW-SECOND\",\"quantity\":5}]"); + var second = new RelabelItem(); second.setProductNameZh("商品乙"); second.setLabelRequirement("贴标要求111"); + var empty = new RelabelItem(); empty.setProductNameZh(" "); + RelabelResponses.Summary result = ReflectionTestUtils.invokeMethod(service, "summary", application, List.of(first, second, empty)); assertNotNull(result); assertEquals(reviewed, result.reviewedAt()); assertEquals(completed, result.relabelCompletedAt()); assertEquals(outbound, result.outboundAt()); + assertEquals("商品甲、商品乙", result.productNameZh()); + assertEquals("贴标要求111", result.labelRequirement()); + assertEquals("SKU-FIRST,SKU-SECOND", result.skuCodes()); + assertEquals(3, result.itemCount()); } @Test void returnInventoryCandidateUsesRealProductNames() { From c84b5f460abffa26eef062b5f6e94f7525841911 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 13 Oct 2026 14:46:22 +0800 Subject: [PATCH 47/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- .../lastleg/relabel/dto/RelabelResponses.java | 5 ++-- .../service/RelabelApplicationService.java | 13 +++++++-- .../openapi/overseas-business-v1.yaml | 9 ++++++ .../RelabelApplicationServiceTest.java | 29 +++++++++++++++++-- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 7854f98b1..462e0ce41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -44,7 +44,8 @@ public final class RelabelResponses { String sourceType, Long returnCartonId, String cartonMark, List skuItems, long relabelledPackages, String relabelStatus, long relabelledQuantity, long relabelReservedQuantity, Long stockQuantity, String inventoryStatus, String occupiedRelabelNo, List mergedIntoCartonMarks, boolean hasOutbound, - List skuConversions) { + List skuConversions, + List cartonSkuItems, long cartonAvailablePackages) { public InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo, String inboundCode, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, @@ -53,7 +54,7 @@ public final class RelabelResponses { this(inventoryBalanceId, inboundOrderId, inboundOrderNo, inboundCode, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, destinationFbaWarehouseCode, destinationFbaWarehouseName, skuCode, productNameZh, availablePackages, version, positions, sourceType, returnCartonId, cartonMark, - skuItems, relabelledPackages, null, relabelledPackages, 0, null, null, null, List.of(), false, List.of()); + skuItems, relabelledPackages, null, relabelledPackages, 0, null, null, null, List.of(), false, List.of(), List.of(), 0); } } public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, 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 6e72bba6d..8f69deb39 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 @@ -284,7 +284,13 @@ public class RelabelApplicationService implements WalletResultHandler { return new RelabelResponses.SkuItem(item.getSkuCode(), null, Math.toIntExact(Math.max(remaining, 0)), null); }).filter(item -> !"AVAILABLE".equals(carton.getStatus()) || item.quantity() > 0).toList(); - if (cartonSkus.isEmpty() && !java.util.Set.of("MERGED", "OUTBOUNDED").contains(carton.getStatus())) continue; + List physicalSkus = cartonLines.stream() + .filter(line -> value(line.getQuantity()) > 0) + .map(line -> new RelabelResponses.SkuItem(line.getSkuCode(), null, Math.toIntExact(value(line.getQuantity())), null)).toList(); + long cartonAvailable = "AVAILABLE".equals(carton.getStatus()) && carton.getRelabelApplicationId() == null + && cartonLines.stream().noneMatch(line -> value(line.getPackingReservedQuantity()) > 0) + && !physicalSkus.isEmpty() ? 1 : 0; + if (cartonSkus.isEmpty() && cartonAvailable == 0 && !java.util.Set.of("MERGED", "OUTBOUNDED").contains(carton.getStatus())) continue; String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) .collect(Collectors.joining(",")); long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); @@ -301,7 +307,7 @@ public class RelabelApplicationService implements WalletResultHandler { .filter(history -> carton.getCartonMark().equals(history.sourceCartonMark())) .map(history -> history.targetCartonMark()).distinct().toList(), returnCartons.hasOutbound(carton.getId()), skuConversionRows.listCarton(customerId, carton.getId()).stream() - .map(com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.SkuConversion::from).toList())); + .map(com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.SkuConversion::from).toList(), physicalSkus, cartonAvailable)); } return result; } @@ -414,7 +420,8 @@ public class RelabelApplicationService implements WalletResultHandler { final long sourceReturnCartonId = carton.getId(); Map availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream() .collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT), - line -> remainingRelabelQuantity(sourceReturnCartonId, line), Long::sum)); + line -> "CARTON_LABEL".equals(operationType(item)) ? value(line.getQuantity()) + : remainingRelabelQuantity(sourceReturnCartonId, line), Long::sum)); if (availableSku.values().stream().noneMatch(quantity -> quantity > 0)) throw conflict("来源箱 SKU 已全部换标,没有剩余可换标数量"); Map requestedSku = selectedReturnSkus.computeIfAbsent(carton.getId(), ignored -> new java.util.HashMap<>()); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 8d5571c86..1fc755f60 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3110,6 +3110,15 @@ components: RelabelInventoryCandidate: type: object properties: + cartonAvailablePackages: { type: integer, format: int64, minimum: 0, maximum: 1, description: 仅换箱唛可选择的当前实物箱数;已换 SKU 不影响可选箱数,占用和合箱预留仍不可选 } + cartonSkuItems: + type: array + description: 当前实物箱内全部 SKU 和件数,包含换标后的新 SKU + items: + type: object + properties: + skuCode: { type: string } + quantity: { type: integer, minimum: 1 } skuConversions: { type: array, items: { $ref: '#/components/schemas/ReturnSkuConversion' }, description: 当前实物关联的旧新SKU转换、实际件数和客户确认状态 } relabelStatus: { type: string, nullable: true, enum: [UNLABELLED, PROCESSING, PARTIALLY_COMPLETED, COMPLETED], description: 退货实物按实际完成件数和未完成申请占用计算 } relabelledPackages: { type: integer, format: int64, description: 退货实际已完成商品换标件数,不从可用库存差值推导 } 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 20608ec9d..2dea60a16 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 @@ -109,11 +109,35 @@ class RelabelApplicationServiceTest { assertEquals(4L, candidate.stockQuantity()); assertEquals("AVAILABLE", candidate.inventoryStatus()); assertEquals(List.of(), candidate.mergedIntoCartonMarks()); + assertEquals(1, candidate.cartonAvailablePackages()); carton.setStatus("RELABEL_PENDING"); var occupied = service.candidates("C", "W", null).get(0); assertEquals(0, occupied.availablePackages()); + assertEquals(0, occupied.cartonAvailablePackages()); assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择"); } + @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); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + ReflectionTestUtils.setField(service, "returnCartonItems", lines); + ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class)); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + carton.setId(20L); carton.setStatus("AVAILABLE"); carton.setCartonMark("NEW-BOX"); carton.setTotalQuantity(15L); + var first = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); + first.setSkuCode("NEW-A"); first.setQuantity(10L); first.setRelabelledQuantity(10L); + var second = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); + second.setSkuCode("NEW-B"); second.setQuantity(5L); second.setRelabelledQuantity(5L); + when(cartons.findRelabelCandidates("C", "W")).thenReturn(List.of(carton)); + when(lines.listCarton(20L)).thenReturn(List.of(first, second)); + var candidate = service.candidates("C", "W", null).get(0); + assertEquals(0, candidate.availablePackages()); + assertTrue(candidate.skuItems().isEmpty()); + assertEquals("NEW-BOX", candidate.cartonMark()); + assertEquals(1, candidate.cartonAvailablePackages()); + assertEquals(List.of(new RelabelResponses.SkuItem("NEW-A", null, 10, null), + new RelabelResponses.SkuItem("NEW-B", null, 5, null)), candidate.cartonSkuItems()); + } @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(longs = {0, 2, 5, 8}) void returnCandidatesOnlyOfferUnlabelledPieces(long completed) { @@ -1066,7 +1090,7 @@ class RelabelApplicationServiceTest { } @org.junit.jupiter.params.ParameterizedTest - @org.junit.jupiter.params.provider.CsvSource({"CARTON_LABEL,0", "SKU_LABEL,0", "SKU_LABEL,2", "CARTON_AND_SKU,0"}) + @org.junit.jupiter.params.provider.CsvSource({"CARTON_LABEL,0", "CARTON_LABEL,5", "SKU_LABEL,0", "SKU_LABEL,2", "CARTON_AND_SKU,0"}) void intactParcelSupportsAllLabelOperationsWithoutMergingIntoBox(String type, long completed) { 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); @@ -1077,7 +1101,8 @@ class RelabelApplicationServiceTest { parcel.setCountStatus("CONFIRMED"); parcel.setTotalQuantity(5L); var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L); when(cartons.lockId(901L)).thenReturn(parcel); when(lines.lockCarton(901L)).thenReturn(List.of(line)); - when(cartons.completedSkuQuantity(901L, "SKU-A")).thenReturn(completed); + if ("CARTON_LABEL".equals(type)) line.setRelabelledQuantity(completed); + else when(cartons.completedSkuQuantity(901L, "SKU-A")).thenReturn(completed); when(cartons.markRelabelPending(anyLong(), anyLong(), anyString(), any())).thenReturn(1); var app = application(11L, "RL-PARCEL", "10001", "parcel-key", null, "PENDING_REVIEW"); app.setLastlegOrderId(21L); when(applications.findNo(eq("10001"), any())).thenReturn(app); From 03601ab0aab997fd8eb73edbf655403caa2cd4c3 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 13 Oct 2026 15:55:31 +0800 Subject: [PATCH 48/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- .../mapper/InventoryPositionMapper.java | 9 +++++++++ .../service/RelabelApplicationService.java | 17 +++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java index 5aa78e426..dc1e70a5d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -9,6 +9,7 @@ import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import java.util.List; +import java.util.Collection; @Mapper public interface InventoryPositionMapper extends BaseMapper { @@ -17,6 +18,14 @@ public interface InventoryPositionMapper extends BaseMapper { List listBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); + @Select({""}) + List listBalances(@Param("customerId") String customerId, + @Param("balanceIds") Collection balanceIds); + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} LIMIT 1") InventoryPosition findTenantPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); 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 8f69deb39..8ff831431 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 @@ -253,10 +253,13 @@ public class RelabelApplicationService implements WalletResultHandler { .gt(InventoryBalance::getAvailablePackages, 0) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .orderByDesc(InventoryBalance::getUpdatedAt)); - List result = new java.util.ArrayList<>(balances.stream().map(balance -> { - List positions = inventoryPositions.listBalance(customerId, balance.getId()) + Map> positionsByBalance = balances.isEmpty() ? Map.of() + : inventoryPositions.listBalances(customerId, balances.stream().map(InventoryBalance::getId).toList()) .stream().filter(position -> value(position.getAvailablePackages()) > 0) - .map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), + .collect(Collectors.groupingBy(InventoryPosition::getBalanceId, LinkedHashMap::new, Collectors.toList())); + List result = new java.util.ArrayList<>(balances.stream().map(balance -> { + List positions = positionsByBalance.getOrDefault(balance.getId(), List.of()) + .stream().map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), position.getBatchNo(), position.getWarehouseLocationCode(), value(position.getAvailablePackages()))) .toList(); long available = positions.stream().mapToLong(RelabelResponses.InventoryPosition::availablePackages).sum(); @@ -298,14 +301,16 @@ public class RelabelApplicationService implements WalletResultHandler { cartonLines.stream().mapToLong(line -> value(line.getRelabelledQuantity()) + returnCartons.completedSkuQuantity(carton.getId(), line.getSkuCode())).sum()); long reserved = returnCartons.relabelReservedQuantity(carton.getId()); + List mergedCartonMarks = StringUtils.hasText(carton.getSourceReturnNo()) ? List.of() + : returnPackingTraces.listCartonHistory(customerId, carton.getCartonMark()).stream() + .filter(history -> carton.getCartonMark().equals(history.sourceCartonMark())) + .map(history -> history.targetCartonMark()).distinct().toList(); result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), skuCodes, productNames, availablePackages, 0, List.of(), "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages, com.baiyue.lastleg.returnreceipt.service.ReturnRelabelProgress.status(value(carton.getTotalQuantity()), relabelledPackages, reserved), relabelledPackages, reserved, java.util.Set.of("MERGED", "OUTBOUNDED").contains(carton.getStatus()) ? 0L : value(carton.getTotalQuantity()), carton.getStatus(), carton.getRelabelNo(), - returnPackingTraces.listCartonHistory(customerId, carton.getCartonMark()).stream() - .filter(history -> carton.getCartonMark().equals(history.sourceCartonMark())) - .map(history -> history.targetCartonMark()).distinct().toList(), returnCartons.hasOutbound(carton.getId()), + mergedCartonMarks, returnCartons.hasOutbound(carton.getId()), skuConversionRows.listCarton(customerId, carton.getId()).stream() .map(com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.SkuConversion::from).toList(), physicalSkus, cartonAvailable)); } From 55764497c810631da7258f2e61fcd7d9b5ceaafb Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 13 Oct 2026 16:48:54 +0800 Subject: [PATCH 49/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- .../returnreceipt/dto/ReturnOrderResponses.java | 15 +++++++++++---- .../service/ReturnInventoryService.java | 6 ++++-- .../service/ReturnReceiptService.java | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) 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 b15224b11..63fbd2952 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 @@ -23,7 +23,13 @@ public final class ReturnOrderResponses { } } public record ReceiptCarton(String receiptNo, String palletCode, String cartonMark, String countStatus, - LocalDateTime countedAt, List items) { } + LocalDateTime countedAt, List items, + BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) { + public ReceiptCarton(String receiptNo, String palletCode, String cartonMark, String countStatus, + LocalDateTime countedAt, List items) { + this(receiptNo, palletCode, cartonMark, countStatus, countedAt, items, null, null, null, null); + } + } public record PackingDetail(String sourceCartonMark, String targetCartonMark, String returnNo, String skuCode, long quantity, LocalDateTime createdAt) { } public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } @@ -40,18 +46,19 @@ public final class ReturnOrderResponses { public record Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy, long receivedQuantity, String operatorName, LocalDateTime signedAt, String unitType, String unitCode, long boxCount, List items, - String cartonMark, String printableHtml, List labels, boolean labelRepairRequired) { + String cartonMark, String printableHtml, List labels, boolean labelRepairRequired, + BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) { public Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy, long receivedQuantity, String operatorName, LocalDateTime signedAt, String unitType, String unitCode, long boxCount, List items, String cartonMark, String printableHtml, List labels) { this(receiptNo, receiptBatchNo, palletCode, trackingNo, startedBy, receivedQuantity, operatorName, signedAt, - unitType, unitCode, boxCount, items, cartonMark, printableHtml, labels, false); + unitType, unitCode, boxCount, items, cartonMark, printableHtml, labels, false, null, null, null, null); } public Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy, long receivedQuantity, String operatorName, LocalDateTime signedAt) { this(receiptNo, receiptBatchNo, palletCode, trackingNo, startedBy, receivedQuantity, operatorName, signedAt, - "PALLET", palletCode, 0, List.of(), null, null, List.of()); + "PALLET", palletCode, 0, List.of(), null, null, List.of(), false, null, null, null, null); } } public record CartonLabel(String cartonMark, String printableHtml) { } 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 445fd8ce8..9720ffbe3 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 @@ -705,11 +705,13 @@ public class ReturnInventoryService { var task = read(snapshot.getResultJson(), new TypeReference() { }); if (!receipt.getReceiptNo().equals(task.receiptNo())) throw new IllegalStateException("箱内清点快照与签收单不一致"); return task.boxes().stream().map(box -> new ReturnOrderResponses.ReceiptCarton(receipt.getReceiptNo(), receipt.getPalletCode(), box.cartonMark(), "CONFIRMED", snapshot.getCreatedAt(), - box.items().stream().map(line -> new ReturnOrderResponses.ReceiptItem(line.skuCode(), line.productNameZh(), line.quantity(), 1)).toList())).toList(); + box.items().stream().map(line -> new ReturnOrderResponses.ReceiptItem(line.skuCode(), line.productNameZh(), line.quantity(), 1)).toList(), + box.length(), box.width(), box.height(), box.weight())).toList(); } // Never present provisional averages or subsequently changed inventory as receipt facts. return cartons.listReceipt(receipt.getId()).stream().map(box -> new ReturnOrderResponses.ReceiptCarton(receipt.getReceiptNo(), receipt.getPalletCode(), box.getCartonMark(), - "PENDING".equals(box.getCountStatus()) ? "PENDING" : "UNRECORDED", box.getCountedAt(), List.of())).toList(); + "PENDING".equals(box.getCountStatus()) ? "PENDING" : "UNRECORDED", box.getCountedAt(), List.of(), + box.getLength(), box.getWidth(), box.getHeight(), box.getWeight())).toList(); } @Transactional(readOnly = true) 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 21f8be528..fe3dd0832 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 @@ -286,7 +286,7 @@ public class ReturnReceiptService { String printableHtml = combineLabels(labels); return new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(), receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt(), receipt.getUnitType(), receipt.getUnitCode(), boxes, lines, cartonMark, printableHtml, labels, - inventory.receiptRepairRequired(receipt, boxes)); + inventory.receiptRepairRequired(receipt, boxes), receipt.getLength(), receipt.getWidth(), receipt.getHeight(), receipt.getWeight()); }).toList(); Map> palletItems = new LinkedHashMap<>(); Map palletTypes = new LinkedHashMap<>(); From 146d468ff434ba9ea23ac1d8bf4a3f11609fd68f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 16:50:53 +0800 Subject: [PATCH 50/80] feat: expose reliable collection activity and stage estimates --- .../model/SelfopProgressProjection.java | 58 ++++++++- .../analytics/model/SelfopStageEstimate.java | 114 ++++++++++++++++++ .../query/CollectionQueryService.java | 80 +++++++++--- .../analytics/query/CollectionReadMapper.java | 25 ++-- .../analytics/query/CollectionResponses.java | 10 +- .../analytics/request/SelfopEgressMapper.java | 4 +- .../sync/SelfopDiscoveryService.java | 12 +- .../analytics/sync/SelfopTaskService.java | 4 +- .../openapi/self-operated-analytics-v1.yaml | 35 ++++++ .../model/SelfopStageEstimateTest.java | 90 ++++++++++++++ .../query/CollectionQueryServiceTest.java | 86 ++++++++++++- .../request/SelfopEgressServiceTest.java | 4 + .../sync/SelfopDiscoveryServiceTest.java | 7 ++ .../sync/SelfopInventoryRecoveryTest.java | 21 ++++ .../sync/SelfopProgressServiceTest.java | 53 ++++++++ 15 files changed, 557 insertions(+), 46 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java index a70f05267..bd72168e0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java @@ -14,16 +14,29 @@ public final class SelfopProgressProjection { "VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED"); /** Pure projection for callers already holding the scope/task/run locks and fence. */ public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan) { + applyCheckpoint(task,run,phase,unit,completed,total,scan,true); + } + public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan,boolean realWork) { require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID"); require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID"); require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID"); Map p=progress(task); - if(!Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()))p=new LinkedHashMap<>(); + if(!Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue())){ + var prior=p;p=new LinkedHashMap<>(); + if(prior.get("localWorkReady") instanceof Boolean + &&Objects.equals(prior.get("localWorkReadyScopeHash"),task.getScopeHash()) + &&Objects.equals(prior.get("localWorkReadyUpstreamTaskId"),task.getUpstreamTaskId()==null?null:task.getUpstreamTaskId().toString())) + for(String key:List.of("localWorkReady","localWorkReadyScopeHash","localWorkReadyUpstreamTaskId"))p.put(key,prior.get(key)); + } + String priorIdentity=SelfopStageEstimate.identity(p); String countScope=scope(task); Object started=p.get("stageStartedAt"); if(!phase.equals(p.get("phase")) || started==null)started=instant(utcNow()); + p.put("taskNo",task.getTaskNo()); p.put("attemptNo",run.getAttemptNo()); p.put("phase",phase); p.put("progressScope",countScope); p.put("stageStartedAt",started); p.put("unit",unit); p.put("completed",completed); p.put("total",total); p.put("totalKnown",total!=null); p.put("scanRound",scan); p.put("countBasis",Set.of("INVENTORY","BUILD").contains(phase)?"ATTEMPT_ONLY":"TASK_CUMULATIVE"); p.put("sliceFrom",instant(task.getQueryFromUtc())); p.put("sliceUntil",instant(task.getQueryUntilUtc())); p.put("stageFinishedAt",null); + if(!priorIdentity.equals(SelfopStageEstimate.identity(p)))p.put("stageStartedAt",instant(utcNow())); + SelfopStageEstimate.checkpoint(p,utcNow().toInstant(ZoneOffset.UTC),realWork); // A zero denominator is meaningful evidence, but cannot produce a percentage. p.put("phasePercent",total==null||total==0?null:BigDecimal.valueOf(completed).multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(total),1,RoundingMode.DOWN)); @@ -42,6 +55,13 @@ public final class SelfopProgressProjection { /** Pure terminal snapshot; historical attempt evidence is immutable. */ public static void freezeRun(SelfopSyncTask task,SelfopSyncRun run) { + // A yielded inventory attempt resumes with the same attempt number. Its live + // sampling baseline must not survive a budget pause or execution interruption. + if(task.getProgressJson()!=null){ + var live=progress(task); + if(Objects.equals(number(live.get("attemptNo")),run.getAttemptNo().longValue())&&live.remove("etaSamples")!=null) + task.setProgressJson(bounded(live)); + } if(run.getProgressSnapshotJson()!=null)return; if(task.getProgressJson()==null) { var fallback=new LinkedHashMap();fallback.put("attemptNo",run.getAttemptNo()); @@ -50,16 +70,50 @@ public final class SelfopProgressProjection { fallback.put("unit","NONE");fallback.put("completed",0);fallback.put("total",null);fallback.put("totalKnown",false); fallback.put("sliceFrom",instant(task.getQueryFromUtc()));fallback.put("sliceUntil",instant(task.getQueryUntilUtc())); fallback.put("stageStartedAt",instant(run.getFetchStartedAtUtc()));fallback.put("stageFinishedAt",null);fallback.put("stages",List.of()); + fallback.put("taskNo",task.getTaskNo());fallback.put("summaryAsOf",instant(utcNow())); run.setProgressSnapshotJson(bounded(fallback)); return; } var p=progress(task); // A later attempt's projection must never be copied into an earlier historical run. require(Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()),"SELFOP_PROGRESS_ATTEMPT_MISMATCH"); + p.remove("etaSamples");p.put("summaryAsOf",instant(utcNow())); run.setProgressSnapshotJson(bounded(p));run.setUpdatedAt(utcNow()); } - private static String bounded(Map p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;} + /** Route commits this proof together with its immutable effective-stage references. */ + public static void localWorkReady(SelfopSyncTask task,boolean ready) { + var p=progress(task);p.put("localWorkReady",ready); + p.put("localWorkReadyScopeHash",task.getScopeHash()); + p.put("localWorkReadyUpstreamTaskId",task.getUpstreamTaskId()==null?null:task.getUpstreamTaskId().toString()); + task.setProgressJson(bounded(p)); + } + + private static String bounded(Map p) { + // Reserve the terminal timestamp before accepting a live checkpoint. Optional + // ETA and derived display fields never displace business/audit checkpoints. + int target=p.containsKey("summaryAsOf")?8192:8192-64; + String value=json(p); + if(bytes(value)>target){p.remove("etaSamples");value=json(p);} + if(bytes(value)>target){p.remove("phasePercent");value=json(p);} + if(bytes(value)>target&&p.get("stages") instanceof List prior){ + var stages=new ArrayList>(); + for(Object entry:prior){var stage=map(entry);stage.remove("phasePercent");stages.add(stage);} + p.put("stages",stages);value=json(p); + // The current top-level stage clock remains available. Duplicated stage + // display clocks can be omitted while retaining every stage count/identity. + if(bytes(value)>target){ + for(var stage:stages){stage.remove("stageStartedAt");stage.remove("stageFinishedAt");} + value=json(p); + } + } + // A pre-existing legacy checkpoint may have used the whole 8 KiB with no + // optional display fields. Missing optional snapshot time is preferable to + // failing its business termination; never invent a replacement timestamp. + if(bytes(value)>8192&&p.containsKey("summaryAsOf")){p.remove("summaryAsOf");value=json(p);} + require(bytes(value)<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value; + } + private static int bytes(String value){return value.getBytes(StandardCharsets.UTF_8).length;} private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";} @SuppressWarnings("unchecked") private static Map progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);} @SuppressWarnings("unchecked") private static Map map(Object value){return value instanceof Map?new LinkedHashMap<>((Map)value):new LinkedHashMap<>();} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java new file mode 100644 index 000000000..276066eb3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java @@ -0,0 +1,114 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Bounded committed-checkpoint sampling; reads never manufacture progress. */ +public final class SelfopStageEstimate { + private SelfopStageEstimate() { } + public static final int WINDOW_SECONDS=180, MIN_SECONDS=60, MIN_ADVANCES=3, + BUCKET_SECONDS=10, MAX_POINTS=20, STALE_SECONDS=60, VALID_SECONDS=10, MAX_BYTES=1024; + public record Estimate(String status,String progressIdentity,Long completed,Long total, + Instant sampleFrom,Instant sampleUntil,Long completedDelta,BigDecimal ratePerSecond, + Long remainingSeconds,Instant estimatedAt,Instant validUntil,String unavailableReason) { } + + public static String identity(Map p) { + var values=new ArrayList(); + for(String key:List.of("taskNo","attemptNo","phase","scanRound","progressScope","unit", + "countBasis","sliceFrom","sliceUntil","total"))values.add(p.get(key)); + return hash(values); + } + + /** A reuse/uncertain checkpoint resets the baseline, never contributes throughput. */ + public static void checkpoint(Map p,Instant now,boolean realWork) { + try { + Long completed=number(p.get("completed")),total=number(p.get("total")); + if(total==null||total<=0||completed==null||completed<0||completed>=total||!Boolean.TRUE.equals(p.get("totalKnown"))||"NONE".equals(p.get("unit"))) { + p.remove("etaSamples");return; + } + String id=identity(p);long second=now.getEpochSecond(); + var old=map(p.get("etaSamples"));var points=points(old.get("p")); + if(!id.equals(old.get("i"))||!realWork||points.isEmpty() + ||second=STALE_SECONDS)points.clear(); + points.removeIf(point->second-point.get(0)>WINDOW_SECONDS); + long advances=points.isEmpty()?0:points.get(points.size()-1).get(2); + long lastAdvance=numberOr(old.get("a"),second); + if(points.isEmpty()) {points.add(List.of(second,completed,0L));lastAdvance=second;} + else { + var last=points.get(points.size()-1); + if(completed>last.get(1)) {advances++;lastAdvance=second;} + var point=List.of(second,completed,advances); + if(last.get(0)/BUCKET_SECONDS==second/BUCKET_SECONDS)points.set(points.size()-1,point); + else points.add(point); + } + while(points.size()>MAX_POINTS)points.remove(0); + var sample=new LinkedHashMap();sample.put("i",id);sample.put("a",lastAdvance);sample.put("p",points); + while(json(sample).getBytes(StandardCharsets.UTF_8).length>MAX_BYTES&&points.size()>1)points.remove(0); + if(json(sample).getBytes(StandardCharsets.UTF_8).length<=MAX_BYTES)p.put("etaSamples",sample); + else p.remove("etaSamples"); + } catch(RuntimeException malformedOptionalSample) {p.remove("etaSamples");} + } + + public static Estimate estimate(Map p,Instant now,boolean healthy,boolean paused,boolean terminal) { + String id=identity(p);Long completed=number(p.get("completed")),total=number(p.get("total")); + String reason=terminal?"TERMINAL":paused?"PAUSED":!healthy?"HEALTH_UNCONFIRMED": + total==null||!Boolean.TRUE.equals(p.get("totalKnown"))?"TOTAL_UNKNOWN":completed==null||completed<0||totalsecond-point.get(0)>WINDOW_SECONDS); + if(points.isEmpty()||second-lastAdvance>=STALE_SECONDS)reason="NO_RECENT_PROGRESS"; + else { + var first=points.get(0);var last=points.get(points.size()-1); + long elapsed=second-first.get(0),advances=last.get(2)-first.get(2); + if(second map(Object value){return value instanceof Map?(Map)value:Map.of();} + private static List> points(Object value) { + var result=new ArrayList>(); + if(value instanceof List list)for(Object item:list) { + if(!(item instanceof List point)||point.size()!=3||result.size()>=MAX_POINTS)throw new IllegalArgumentException("Invalid bounded sample"); + var parsed=List.of(Objects.requireNonNull(number(point.get(0))),Objects.requireNonNull(number(point.get(1))),Objects.requireNonNull(number(point.get(2)))); + if(parsed.stream().anyMatch(n->n<0))throw new IllegalArgumentException("Negative sample"); + if(!result.isEmpty()){ + var prior=result.get(result.size()-1); + if(parsed.get(0)<=prior.get(0)||parsed.get(1) TERMINAL = Set.of("ROUTED","PUBLISHED","REUSED","YIELDED","FAILED","ABORTED","SAMPLE_DONE"); + private static final Set ACTIVE = Set.of("RUNNING","READY","QUEUED","WAITING","WAITING_SOURCE","RETRY_WAIT","YIELDED"); private static final Set PROGRESS_FIELDS = Set.of("progressScope","countBasis","attemptNo","scanRound","phase", "completed","total","totalKnown","unit","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt", "validationState","invalidCount","unknownCount","pendingReviewCount","countUnit","summaryAsOf","directoryComplete"); @@ -76,14 +78,20 @@ public class CollectionQueryService { for(String family:List.of("OUTBOUND","INVENTORY")) { if(query.dataFamily()!=null&&!family.equals(query.dataFamily()))continue; Scope scope=persisted.get(warehouse+":"+family); - Task current=currentTasks.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null); + List candidates=new ArrayList<>(currentTasks.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).toList()); Task sampled=validationTasks.stream().filter(t->warehouse.equals(t.warehouseCode())).findFirst().orElse(null); - if(sampled!=null&&(current==null||sampled.createdAt()!=null&¤t.createdAt()!=null&&sampled.createdAt().isAfter(current.createdAt())))current=sampled; + if(sampled!=null)candidates.add(sampled); + candidates.sort(Comparator.comparingInt((Task t)->"RUNNING".equals(t.status())?0:ACTIVE.contains(t.status())?1:2) + .thenComparing(Task::createdAt,Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(Task::id,Comparator.nullsLast(Comparator.reverseOrder()))); + Task current=candidates.isEmpty()?null:candidates.get(0); + List activity=candidates.stream().filter(t->ACTIVE.contains(t.status())).limit(8) + .map(t->taskView(t,scope,protection,context,family)).toList(); Task last=successes.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null); Attempt published=scope==null?null:context.published().get(scope.publishedRunId()); if(published!=null&&(!warehouse.equals(published.warehouseCode())||!Objects.equals(scope.id(),published.scopeId()) ||!Objects.equals(scope.scopeHash(),published.scopeHash())))published=null; - Task shared="OUTBOUND".equals(family)&&sharedWindowRelated(active,scope)?active:null; + Task shared=current==null?null:relatedUpstream(current,context); String blocked=executionBlock(warehouse,protection); boolean enabled=blocked==null; boolean publication="BUSINESS".equals(config.getCollectionMode())&&config.isExecutionEnabled() @@ -104,10 +112,11 @@ public class CollectionQueryService { "INVENTORY".equals(family)&&published!=null?utc(published.fetchFinishedAtUtc()):null,freshness, "OUTBOUND".equals(family)&&scope!=null&&(Integer.valueOf(1).equals(scope.rebuildRequired())||barriers.contains(warehouse)),reviewStatus(scope), current==null?null:taskView(current,scope,protection,context,family),shared==null?null:taskView(shared,null,protection,context),protection, - "INVENTORY".equals(family)?inventorySamples.get(warehouse):null)); + "INVENTORY".equals(family)?inventorySamples.get(warehouse):null,activity)); } } - return new CollectionResponses.Overview(query.sourceAccount(),config.getEnvironment(),config.getCollectionMode(),clock.instant(),rows); + return new CollectionResponses.Overview(query.sourceAccount(),config.getEnvironment(),config.getCollectionMode(),clock.instant(),rows, + active!=null&&ACTIVE.contains(active.status())&&"BUSINESS".equals(active.executionPurpose())?taskView(active,null,protection,context):null); } private Map inventorySamples(CollectionQuery query) { @@ -181,14 +190,6 @@ public class CollectionQueryService { for(Scope scope:mapper.scopes(q.sourceAccount(),q.warehouseCode()))scopes.put(scope.dataFamily(),scope); return scopes; } - private boolean sharedWindowRelated(Task active,Scope scope) { - if(active==null||scope==null||active.queryFromUtc()==null||active.queryUntilUtc()==null)return false; - return overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.initialFromUtc(),scope.initialUntilUtc()) - ||overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.discoveryFromUtc(),scope.discoveryUntilUtc()); - } - private static boolean overlaps(LocalDateTime from,LocalDateTime until,LocalDateTime otherFrom,LocalDateTime otherUntil) { - return otherFrom!=null&&otherUntil!=null&&from.isBefore(otherUntil)&&until.isAfter(otherFrom); - } private String reviewStatus(Scope scope) { if(scope==null||scope.reviewTargetDays()==null||scope.reviewTargetDays()<1)return "UNKNOWN"; if(scope.knownOrderRoundFinishedAtUtc()==null||scope.historyRoundFinishedAtUtc()==null)return "PENDING"; @@ -199,6 +200,7 @@ public class CollectionQueryService { if(task.upstreamTaskId()==null)return null; Task parent=context.tasks().get(task.upstreamTaskId()); return parent!=null&&"ACCOUNT_DISCOVERY".equals(parent.taskRole())&&Objects.equals(task.sourceAccount(),parent.sourceAccount()) + &&Objects.equals(task.executionPurpose(),parent.executionPurpose()) &&(!"VALIDATION".equals(parent.executionPurpose())||validationAccountRoot(parent)&&Objects.equals(task.warehouseCode(),parent.warehouseCode()))?parent:null; } private static boolean validationAccountRoot(Task task) { @@ -223,11 +225,55 @@ public class CollectionQueryService { String resultState=t.resultPublishedRunId()==null?"NOT_PUBLISHED":result==null||scope==null||!Objects.equals(scope.id(),result.scopeId()) ||!Objects.equals(t.warehouseCode(),result.warehouseCode())||!Objects.equals(t.scopeHash(),result.scopeHash())?"RESULT_UNAVAILABLE" :latest!=null&&"REUSED".equals(latest.status())?"REUSED":latest!=null&&"PUBLISHED".equals(latest.status())?"PUBLISHED":"RESULT_AVAILABLE"; + Task parent=relatedUpstream(t,context); + String dependency=dependencyState(t,parent); + String waiting=blockingReason(t,dependency,protection); + CollectionResponses.Attempt attempt=latest==null?null:attemptView(latest,t,blocked==null); return new CollectionResponses.Task(t.taskNo(),t.taskType(),validationAccountRoot(t)&&family!=null?family:t.dataFamily(),t.warehouseCode(),t.taskRole(),t.executionPurpose(), utc(t.queryFromUtc()),utc(t.queryUntilUtc()),t.status(),t.applyState(),t.attemptCount(),latest==null?null:latest.attemptNo(), resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()), blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()), - validation(root==null?t:root),latest==null?null:attemptView(latest,t,blocked==null)); + validation(root==null?t:root),attempt,dependency,waiting, + "WAITING_CONFIRMED".equals(dependency)?parent.taskNo():t.taskNo(),stageEstimate(t,latest,attempt,waiting)); + } + private String dependencyState(Task task,Task parent) { + if(task.upstreamTaskId()==null)return "WAITING_SOURCE".equals(task.status())?"WAITING_UNCONFIRMED":"NONE"; + if(parent==null)return "WAITING_UNCONFIRMED"; + if("SUCCEEDED".equals(parent.status()))return "SATISFIED"; + if(Set.of("DEAD","FAILED","ABORTED","CANCELLED").contains(parent.status()))return "FAILED"; + return "WAITING_CONFIRMED"; + } + private String blockingReason(Task task,String dependency,CollectionResponses.Protection protection) { + if(!ACTIVE.contains(task.status()))return null; + String disabled=executionBlock(task.warehouseCode(),protection); + if(disabled!=null)return disabled; + if("test".equals(config.getEnvironment())&&"BUSINESS".equals(task.executionPurpose())) { + String budget=businessBudgetBlock(task);if(budget!=null)return budget; + } + if("WAITING_UNCONFIRMED".equals(dependency))return "UPSTREAM_UNCONFIRMED"; + if("WAITING_CONFIRMED".equals(dependency))return "UPSTREAM_NOT_READY"; + if("FAILED".equals(dependency))return "UPSTREAM_FAILED"; + if("RETRY_WAIT".equals(task.status()))return "RETRY_SCHEDULED"; + if("YIELDED".equals(task.status()))return "EXECUTION_YIELDED"; + if(Set.of("READY","QUEUED","WAITING").contains(task.status()))return "QUEUED"; + return null; + } + private SelfopStageEstimate.Estimate stageEstimate(Task task,Attempt run,CollectionResponses.Attempt attempt,String blocked) { + boolean terminal=!ACTIVE.contains(task.status())||run!=null&&TERMINAL.contains(run.status()); + String source=task.progressJson(); + JsonNode bounded=boundedObject(source); + Map raw=bounded==null?new LinkedHashMap<>():json.convertValue(bounded,new com.fasterxml.jackson.core.type.TypeReference>(){}); + raw.putAll(progress(source)); + raw.put("taskNo",task.taskNo()); + boolean healthy="RUNNING".equals(task.status())&&attempt!=null&&"HEALTHY".equals(attempt.executionHealth()) + &&Objects.equals(task.attemptCount(),attempt.attemptNo())&&Objects.equals(number(bounded,"attemptNo"),attempt.attemptNo()==null?null:attempt.attemptNo().longValue()); + var estimate=SelfopStageEstimate.estimate(raw,clock.instant(),healthy,blocked!=null,terminal); + if("AVAILABLE".equals(estimate.status())&&attempt.heartbeatAt()!=null) { + Instant healthUntil=attempt.heartbeatAt().plusSeconds(config.getLeaseSeconds()); + if(healthUntil.isBefore(estimate.validUntil()))return new SelfopStageEstimate.Estimate(estimate.status(),estimate.progressIdentity(),estimate.completed(),estimate.total(), + estimate.sampleFrom(),estimate.sampleUntil(),estimate.completedDelta(),estimate.ratePerSecond(),estimate.remainingSeconds(),estimate.estimatedAt(),healthUntil,null); + } + return estimate; } private String retryBlock(Task task,Scope scope,CollectionResponses.Protection protection,Context context) { if(!"BUSINESS".equals(task.executionPurpose()))return "VALIDATION_NOT_RETRYABLE"; @@ -294,9 +340,9 @@ public class CollectionQueryService { if(!Set.of("BUSINESS","VALIDATION").contains(config.getCollectionMode()))return "COLLECTION_MODE_INVALID"; if("BUSINESS".equals(config.getCollectionMode())&&!com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.businessEnvironment(config))return "BUSINESS_ENVIRONMENT_REJECTED"; if(!config.isExecutionEnabled())return "EXECUTION_DISABLED"; - if(!config.getWarehouses().containsKey(warehouse))return "WAREHOUSE_NOT_CONFIGURED"; - var w=config.getWarehouses().get(warehouse); - if(!"Asia/Shanghai".equals(w.getBusinessTimezone())||!"Asia/Shanghai".equals(w.getSourceTimezone())||!"PCS".equals(config.getQuantityUnit()))return "TIMEZONE_OR_UNIT_NOT_CONFIRMED"; + if(warehouse!=null&&!config.getWarehouses().containsKey(warehouse))return "WAREHOUSE_NOT_CONFIGURED"; + var w=warehouse==null?null:config.getWarehouses().get(warehouse); + if(w!=null&&(!"Asia/Shanghai".equals(w.getBusinessTimezone())||!"Asia/Shanghai".equals(w.getSourceTimezone()))||!"PCS".equals(config.getQuantityUnit()))return "TIMEZONE_OR_UNIT_NOT_CONFIRMED"; if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED"; if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java index e882c4872..6981a31e2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java @@ -39,16 +39,14 @@ public interface CollectionReadMapper { + " AND JSON_TYPE(JSON_EXTRACT(t.query_scope_json,'$.warehouses[0]'))='STRING')"; String TASK_WAREHOUSE = "CASE WHEN " + VALIDATION_ROOT + " THEN JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouses[0]')) ELSE t.warehouse_code END"; - String TASK_FAMILY = "CASE WHEN " + VALIDATION_ROOT + " THEN 'OUTBOUND' ELSE s.data_family END"; + String TASK_FAMILY = "CASE WHEN t.task_role='ACCOUNT_DISCOVERY' THEN 'OUTBOUND' ELSE s.data_family END"; String TASK_MEMBERSHIP = "((t.task_role='WAREHOUSE_SYNC' AND s.id IS NOT NULL) OR " + VALIDATION_ROOT + ")"; String BUSINESS_LOCAL_WORK_READY = "CASE WHEN t.task_role='WAREHOUSE_SYNC' AND s.data_family='OUTBOUND'" + " AND (t.task_type='REBUILD' OR t.apply_state='APPLIED' OR (" - + "JSON_EXTRACT(t.routing_manifest_json,'$.directoryComplete')=CAST('true' AS JSON)" - + " AND JSON_TYPE(JSON_EXTRACT(t.routing_manifest_json,'$.objects'))='OBJECT'" - + " AND NOT EXISTS (SELECT 1 FROM JSON_TABLE(t.routing_manifest_json,'$.objects.*'" - + " COLUMNS(entry JSON PATH '$')) manifest_item" - + " WHERE COALESCE(JSON_TYPE(JSON_EXTRACT(manifest_item.entry,'$.effectiveStageId')),'NULL') NOT IN ('INTEGER')" - + " OR (CAST(JSON_UNQUOTE(JSON_EXTRACT(manifest_item.entry,'$.effectiveStageId')) AS DECIMAL(65,0)) BETWEEN 1 AND 9223372036854775807) IS NOT TRUE)))" + + "OCTET_LENGTH(t.progress_json) BETWEEN 0 AND 8192 AND JSON_EXTRACT(t.progress_json,'$.localWorkReady')=CAST('true' AS JSON)" + + " AND JSON_UNQUOTE(JSON_EXTRACT(t.progress_json,'$.localWorkReadyScopeHash'))=t.scope_hash" + + " AND ((t.upstream_task_id IS NULL AND JSON_TYPE(JSON_EXTRACT(t.progress_json,'$.localWorkReadyUpstreamTaskId'))='NULL')" + + " OR (t.upstream_task_id IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(t.progress_json,'$.localWorkReadyUpstreamTaskId'))=CAST(t.upstream_task_id AS CHAR)))))" + " THEN TRUE ELSE FALSE END"; String TASK = "t.id,t.task_no,t.source_account," + TASK_WAREHOUSE + " AS warehouse_code," + TASK_FAMILY + " AS data_family,t.task_role,t.task_type," + "t.execution_purpose,t.scope_hash,t.status,t.apply_state,t.attempt_count,t.query_from_utc,t.query_until_utc," @@ -123,14 +121,17 @@ public interface CollectionReadMapper { + " WHERE r.source_account=#{account} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC'" + " AND r.id IN #{id}") List publishedRuns(@Param("account") String account,@Param("ids") List ids); + String ACTIVE_STATUS = "('RUNNING','READY','QUEUED','WAITING','WAITING_SOURCE','RETRY_WAIT','YIELDED')"; + String ACTIVITY_ORDER = "CASE WHEN status='RUNNING' THEN 0 WHEN status IN " + ACTIVE_STATUS + " THEN 1 ELSE 2 END"; @Select("") + + " JOIN (SELECT id,ROW_NUMBER() OVER(PARTITION BY scope_id ORDER BY " + ACTIVITY_ORDER + ",created_at DESC,id DESC) rn FROM ll_selfop_sync_task" + + " WHERE source_account=#{account} AND task_role='WAREHOUSE_SYNC' AND scope_id IN #{id}) ranked" + + " ON ranked.id=t.id AND ranked.rn <= 8 WHERE t.source_account=#{account} AND (t.status IN " + ACTIVE_STATUS + " OR ranked.rn=1)" + + " ORDER BY t.scope_id,ranked.rn") List currentTasks(@Param("account") String account,@Param("ids") List ids); @Select("") @@ -141,7 +142,7 @@ public interface CollectionReadMapper { + " #{id}) ranked" + " ON ranked.id=t.id AND ranked.rn=1 WHERE t.source_account=#{account}") List lastSuccessfulTasks(@Param("account") String account,@Param("ids") List ids); - // The one account active task is separately related to warehouses by actual persisted windows. + // Account activity is independent; only upstream_task_id proves a warehouse dependency. @Select("SELECT " + TASK + " FROM ll_selfop_outbound_discovery_scope d JOIN ll_selfop_sync_task t ON t.id=d.active_task_id" + " AND t.source_account=d.source_account LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id" + BUSINESS_ROOT_JOIN diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java index 3d9aec8b4..8bc333f9a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java @@ -5,13 +5,14 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopStageEstimate; public final class CollectionResponses { private CollectionResponses() {} public record Page(int current, int size, long total, Instant listUpperCreatedAt, Long listUpperId, Instant observedAt, List records) {} public record Overview(String sourceAccount, String environmentLabel, String collectionMode, - Instant observedAt, List warehouses) {} + Instant observedAt, List warehouses, Task accountActivity) {} public record Warehouse(String warehouseCode, String warehouseName, String dataFamily, boolean executionEnabled, boolean publicationEnabled, String disabledReason, Instant nextScheduledAt, Instant observedAt, Instant initialFrom, Instant initialUntil, @@ -19,7 +20,8 @@ public final class CollectionResponses { String lastSuccessfulTaskNo, Instant lastSucceededAt, Instant lastPublishedAt, Instant inventoryObservedFrom, Instant inventoryObservedUntil, String dataFreshness, boolean rebuildRequired, String revisionReviewStatus, Task currentTask, - Task sharedUpstream, Protection protection, InventoryDailySamples inventoryDailySamples) {} + Task sharedUpstream, Protection protection, InventoryDailySamples inventoryDailySamples, + List activeTasks) {} public record InventoryDailySamples(LocalDate windowFrom, LocalDate windowUntil, int expectedDays, int sampleCount, List missingDates, LocalDate accumulationStartedOn) {} public record Task(String taskNo, String taskType, String dataFamily, String warehouseCode, @@ -27,7 +29,9 @@ public final class CollectionResponses { String taskStatus, String applyState, Integer attemptCount, Integer latestAttemptNo, String resultPublicationState, Long resultPublishedRunId, Instant nextRetryAt, List allowedActions, String actionBlockReason, Instant progressUpdatedAt, - Map progress, Validation validation, Attempt latestAttempt) {} + Map progress, Validation validation, Attempt latestAttempt, + String dependencyState, String blockingReason, String progressSourceTaskNo, + SelfopStageEstimate.Estimate stageEstimate) {} public record TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {} public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase, String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java index f1b8c564e..fe1d42378 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java @@ -11,6 +11,6 @@ public interface SelfopEgressMapper { @Update("UPDATE ll_selfop_outbound_discovery_scope SET api_budget_json=#{apiBudgetJson},api_cooldown_until_utc=#{apiCooldownUntilUtc},api_pause_reason=#{apiPauseReason},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveAccount(SelfopOutboundDiscoveryScope row); @Update("UPDATE ll_selfop_sync_task SET validation_budget_json=#{validationBudgetJson},business_budget_json=#{businessBudgetJson},status=#{status},next_retry_at_utc=#{nextRetryAtUtc},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveRoot(SelfopSyncTask row); @Update("UPDATE ll_selfop_sync_task SET status='DEAD',next_retry_at_utc=NULL,updated_at=UTC_TIMESTAMP(3) WHERE business_root_task_id=#{root} AND source_account=#{account} AND execution_purpose='BUSINESS' AND status IN ('WAITING_SOURCE','READY','RUNNING','RETRY_WAIT')") int terminateBusinessTasks(@Param("root") Long root,@Param("account") String account); - @Update("UPDATE ll_selfop_sync_run SET status='FAILED',error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT progress_json FROM ll_selfop_sync_task WHERE id=task_id) WHERE source_account=#{account} AND execution_purpose='BUSINESS' AND task_id IN (SELECT id FROM ll_selfop_sync_task WHERE business_root_task_id=#{root} AND source_account=#{account} AND execution_purpose='BUSINESS') AND status IN ('FETCHING','VALIDATED','YIELDED','READY','BUILDING')") int terminateBusinessRuns(@Param("root") Long root,@Param("account") String account,@Param("reason") String reason,@Param("now") LocalDateTime now); - @Update("UPDATE ll_selfop_sync_run SET status=#{status},error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT progress_json FROM ll_selfop_sync_task WHERE id=task_id) WHERE task_id IN (SELECT id FROM ll_selfop_sync_task WHERE validation_root_task_id=#{root}) AND status IN ('FETCHING','VALIDATED','YIELDED')") int terminateValidationRuns(@Param("root") Long root,@Param("status") String status,@Param("reason") String reason,@Param("now") LocalDateTime now); + @Update("UPDATE ll_selfop_sync_run SET status='FAILED',error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT JSON_SET(JSON_REMOVE(COALESCE(progress_json,JSON_OBJECT()),'$.etaSamples'),'$.summaryAsOf',CONCAT(DATE_FORMAT(#{now},'%Y-%m-%dT%H:%i:%s.%f'),'Z')) FROM ll_selfop_sync_task WHERE id=task_id AND (progress_json IS NULL OR JSON_EXTRACT(progress_json,'$.attemptNo')=attempt_no)) WHERE source_account=#{account} AND execution_purpose='BUSINESS' AND task_id IN (SELECT id FROM ll_selfop_sync_task WHERE business_root_task_id=#{root} AND source_account=#{account} AND execution_purpose='BUSINESS') AND status IN ('FETCHING','VALIDATED','YIELDED','READY','BUILDING')") int terminateBusinessRuns(@Param("root") Long root,@Param("account") String account,@Param("reason") String reason,@Param("now") LocalDateTime now); + @Update("UPDATE ll_selfop_sync_run SET status=#{status},error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT JSON_SET(JSON_REMOVE(COALESCE(progress_json,JSON_OBJECT()),'$.etaSamples'),'$.summaryAsOf',CONCAT(DATE_FORMAT(#{now},'%Y-%m-%dT%H:%i:%s.%f'),'Z')) FROM ll_selfop_sync_task WHERE id=task_id AND (progress_json IS NULL OR JSON_EXTRACT(progress_json,'$.attemptNo')=attempt_no)) WHERE task_id IN (SELECT id FROM ll_selfop_sync_task WHERE validation_root_task_id=#{root}) AND status IN ('FETCHING','VALIDATED','YIELDED')") int terminateValidationRuns(@Param("root") Long root,@Param("status") String status,@Param("reason") String reason,@Param("now") LocalDateTime now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java index 54835175f..11dec60f0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -153,10 +153,11 @@ public class SelfopDiscoveryService { full=stage(finalFetchTask,finalFetchRun,acceptedDetail,existing==null?1:existing.getFirstPageNo(),acceptedObservation,"VALID"); if(existing!=null){full.setId(existing.getId());full.setRunId(existing.getRunId());full.setCreatedAt(existing.getCreatedAt());stages.updateById(full);}else stages.insert(full); } - ref.setEffectiveStageId(full.getId());ref.setWarehouseCode(acceptedDetail.warehouseCode());saveManifest(checkpoint.task(),checkpoint.run(),manifest); + ref.setEffectiveStageId(full.getId());ref.setWarehouseCode(acceptedDetail.warehouseCode());saveManifest(checkpoint.task(),checkpoint.run(),manifest,existing==null||!"VALID".equals(existing.getValidationStatus())); if(!currentFetchTask.getId().equals(task.getId())){var refreshManifest=new SelfopRoutingManifest();refreshManifest.setDirectoryComplete(true);refreshManifest.setPages(1);refreshManifest.setTotal(1L); var refreshRef=new SelfopRoutingManifest.Entry();refreshRef.setCustomerCode(ref.getCustomerCode());refreshRef.setDeliveryNo(ref.getDeliveryNo());refreshRef.setWarehouseCode(ref.getWarehouseCode());refreshRef.setEffectiveStageId(full.getId()); refreshManifest.getObjects().put(entry.getKey(),refreshRef);currentFetchTask.setRoutingManifestJson(json(refreshManifest)); + SelfopProgressProjection.applyCheckpoint(currentFetchTask,currentFetchRun,"DETAIL","ORDER",1,1L,refreshManifest.getScan(),existing==null||!"VALID".equals(existing.getValidationStatus())); SelfopProgressProjection.freezeRun(currentFetchTask,currentFetchRun);currentFetchRun.setStatus("ROUTED");currentFetchRun.setFetchFinishedAtUtc(utcNow());currentFetchRun.setFinishedAtUtc(utcNow());currentFetchRun.setUpdatedAt(utcNow());runs.updateById(currentFetchRun); currentFetchTask.setStatus("SUCCEEDED");currentFetchTask.setUpdatedAt(utcNow());tasks.updateById(currentFetchTask);} }); @@ -204,10 +205,14 @@ public class SelfopDiscoveryService { s.setContentHash(SelfopOutboundFingerprint.order(payload));s.setPayloadJson(json(payload));s.setValidationStatus(status);s.setCreatedAt(utcNow());s.setUpdatedAt(utcNow());return s; } private void saveManifest(SelfopSyncTask task,SelfopSyncRun active,SelfopRoutingManifest manifest){ + // Directory pages are freshly fetched. Detail reference reuse cannot establish throughput. + saveManifest(task,active,manifest,!manifest.isDirectoryComplete()); + } + private void saveManifest(SelfopSyncTask task,SelfopSyncRun active,SelfopRoutingManifest manifest,boolean realWork){ task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow()); SelfopProgressProjection.applyCheckpoint(task,active,manifest.isDirectoryComplete()?"DETAIL":"DIRECTORY",manifest.isDirectoryComplete()?"ORDER":"PAGE", manifest.isDirectoryComplete()?manifest.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count():Math.max(0,manifest.getNextPage()-1), - manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan()); + manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan(),realWork); runs.updateById(active);tasks.updateById(task); } private void route(SelfopSyncTask parent,SelfopSyncRun parentRun,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease,boolean detailsResolved){ @@ -238,7 +243,8 @@ public class SelfopDiscoveryService { child.setQueryScopeJson(scope.getScopeJson());child.setScopeHash(scope.getScopeHash());child.setRequestHash(hash(List.of(parent.getId(),scope.getScopeHash())));child.setStatus(ready?"READY":"WAITING_SOURCE");child.setApplyState("NOT_STARTED"); var saved=taskService.create(child); if(!Set.of("SUCCEEDED","DEAD","SAMPLE_DONE").contains(saved.getStatus())&& !Set.of("APPLYING","APPLIED","BLOCKED").contains(saved.getApplyState())){ - saved.setRoutingManifestJson(json(own));saved.setStatus(ready?"READY":"WAITING_SOURCE");saved.setUpdatedAt(utcNow());tasks.updateById(saved);} + saved.setRoutingManifestJson(json(own));saved.setStatus(ready?"READY":"WAITING_SOURCE"); + SelfopProgressProjection.localWorkReady(saved,ready);saved.setUpdatedAt(utcNow());tasks.updateById(saved);} } }); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java index adbe9bc88..3e15db987 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -145,8 +145,8 @@ public class SelfopTaskService { var now=utcNow();var active=runs.selectOne(new QueryWrapper().eq("task_id",taskId).eq("status","FETCHING").orderByDesc("id").last("LIMIT 1")); if(task.getScopeId()!=null && "INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { var yielded=runs.selectOne(new QueryWrapper().eq("task_id",taskId).eq("status","YIELDED").orderByDesc("id").last("LIMIT 1")); - if(yielded!=null){yielded.setStatus("FETCHING");yielded.setFinishedAtUtc(null);yielded.setFenceToken(lease.fence());yielded.setUpdatedAt(now); - runs.update(null,new UpdateWrapper().eq("id",yielded.getId()).set("status","FETCHING").set("fence_token",lease.fence()).set("finished_at_utc",null).set("updated_at",now)); + if(yielded!=null){yielded.setStatus("FETCHING");yielded.setFinishedAtUtc(null);yielded.setProgressSnapshotJson(null);yielded.setFenceToken(lease.fence());yielded.setUpdatedAt(now); + runs.update(null,new UpdateWrapper().eq("id",yielded.getId()).set("status","FETCHING").set("fence_token",lease.fence()).set("finished_at_utc",null).set("progress_snapshot_json",null).set("updated_at",now)); task.setStatus("RUNNING");task.setUpdatedAt(now);tasks.updateById(task);return yielded;} } if(active!=null){require(!Objects.equals(active.getFenceToken(),lease.fence()),"SELFOP_TASK_ALREADY_RUNNING"); diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index 33dd624b9..cae503ab9 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -750,6 +750,32 @@ components: progress: {$ref: '#/components/schemas/Progress'} validation: {$ref: '#/components/schemas/Validation'} latestAttempt: {$ref: '#/components/schemas/Attempt'} + dependencyState: + type: string + enum: [NONE, WAITING_CONFIRMED, WAITING_UNCONFIRMED, SATISFIED, FAILED] + description: 仅直接upstreamTaskId且账号及用途一致证明依赖;时间窗口相交不构成依赖。 + blockingReason: {type: string, nullable: true, description: 当前执行阻塞原因码,与恢复操作资格actionBlockReason分开} + progressSourceTaskNo: {type: string, nullable: true, description: 确认等待时指向直接上游,否则指向本任务;计数仍保留在各自Task.progress,不复制到本仓} + stageEstimate: {$ref: '#/components/schemas/StageEstimate'} + StageEstimate: + type: object + description: 本任务当前阶段估算,不能当作全任务ETA;进度与估算整体按progressIdentity和validUntil接收。计数包含复用时测速基线重建,采样仅来自已提交检查点。 + properties: + status: {type: string, enum: [AVAILABLE, UNAVAILABLE, PAUSED]} + progressIdentity: {type: string} + completed: {type: string, nullable: true} + total: {type: string, nullable: true} + sampleFrom: {type: string, format: date-time, nullable: true} + sampleUntil: {type: string, format: date-time, nullable: true} + completedDelta: {type: string, nullable: true} + ratePerSecond: {type: number, nullable: true} + remainingSeconds: {type: string, nullable: true} + estimatedAt: {type: string, format: date-time} + validUntil: {type: string, format: date-time, nullable: true} + unavailableReason: + type: string + nullable: true + enum: [TERMINAL, PAUSED, HEALTH_UNCONFIRMED, TOTAL_UNKNOWN, INVALID_PROGRESS, STAGE_COMPLETE, SAMPLING, NO_RECENT_PROGRESS, SAMPLE_EXPIRED, INVALID_SAMPLE] TaskDetail: type: object properties: @@ -795,6 +821,11 @@ components: revisionReviewStatus: {type: string, nullable: true} inventoryDailySamples: {$ref: '#/components/schemas/InventoryDailySamples'} currentTask: {$ref: '#/components/schemas/Task'} + activeTasks: + type: array + maxItems: 8 + description: 本仓本类型活动或等待任务,RUNNING优先,再按创建时间倒序;currentTask取活动首项,无活动时取最近任务。 + items: {$ref: '#/components/schemas/Task'} sharedUpstream: {$ref: '#/components/schemas/Task'} protection: {$ref: '#/components/schemas/Protection'} Overview: @@ -805,6 +836,10 @@ components: collectionMode: {type: string, nullable: true} observedAt: {type: string, format: date-time, nullable: true} warehouses: {type: array, items: {$ref: '#/components/schemas/Warehouse'}} + accountActivity: + allOf: [{$ref: '#/components/schemas/Task'}] + nullable: true + description: 来源账号明确活动指针指向的正式采集任务,独立展示,不代表任一仓库的依赖或数量。 Progress: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java new file mode 100644 index 000000000..bf712d1fd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java @@ -0,0 +1,90 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +class SelfopStageEstimateTest { + private final Instant start=Instant.parse("2026-10-13T00:00:00Z"); + private Map progress(){ + var p=new LinkedHashMap();p.put("taskNo","LXCOL-ETA");p.put("attemptNo",1); + p.put("phase","DETAIL");p.put("scanRound",1);p.put("progressScope","ACCOUNT_SHARED"); + p.put("unit","ORDER");p.put("countBasis","TASK_CUMULATIVE");p.put("total",1000L);p.put("totalKnown",true); + p.put("sliceFrom","2026-10-12T00:00:00Z");p.put("sliceUntil","2026-10-13T00:00:00Z");return p; + } + private void commit(Map p,long seconds,long completed,boolean real){ + p.put("completed",completed);SelfopStageEstimate.checkpoint(p,start.plusSeconds(seconds),real); + } + private Map ready(){var p=progress();for(int i=0;i<=3;i++)commit(p,i*20,1+i*20,true);return p;} + private SelfopStageEstimate.Estimate estimate(Map p,long second){return SelfopStageEstimate.estimate(p,start.plusSeconds(second),true,false,false);} + @Test void sufficientRealAdvancesEstimateCurrentStageAndIncludeIdleTime(){ + var p=ready();var e=estimate(p,60);assertEquals("AVAILABLE",e.status());assertEquals(60L,e.completedDelta()); + assertEquals(0,new BigDecimal("1").compareTo(e.ratePerSecond()));assertEquals(939L,e.remainingSeconds()); + assertEquals(start,e.sampleFrom());assertEquals(start.plusSeconds(60),e.sampleUntil());assertEquals(start.plusSeconds(70),e.validUntil()); + var idle=estimate(p,90);assertEquals("AVAILABLE",idle.status());assertEquals(new BigDecimal("0.666666"),idle.ratePerSecond()); + assertEquals(1409L,idle.remainingSeconds());assertEquals("NO_RECENT_PROGRESS",estimate(p,120).unavailableReason()); + } + @Test void shortOrInsufficientAdvanceWindowsNeverEstimate(){ + var p=progress();commit(p,0,1,true);commit(p,20,2,true);commit(p,40,3,true); + assertEquals("SAMPLING",estimate(p,59).unavailableReason());assertEquals("SAMPLING",estimate(p,60).unavailableReason()); + commit(p,60,4,true);assertEquals("AVAILABLE",estimate(p,60).status()); + } + @Test void reuseBeforeInsideAndAfterWindowRestartsBaselineInsteadOfInflatingRate(){ + var p=progress();commit(p,0,500,false);commit(p,20,501,true);commit(p,40,502,true);commit(p,60,503,true); + assertEquals(3L,estimate(p,60).completedDelta()); + commit(p,70,900,false);assertEquals("SAMPLING",estimate(p,70).unavailableReason()); + commit(p,90,901,true);commit(p,110,902,true);commit(p,130,903,true);assertEquals(3L,estimate(p,130).completedDelta()); + commit(p,400,950,false);assertEquals("SAMPLING",estimate(p,400).unavailableReason()); + } + @Test void eachIdentityDimensionInvalidatesEarlierEstimateAndCheckpointRebuildsIt(){ + for(String key:List.of("taskNo","attemptNo","phase","scanRound","progressScope","unit","countBasis","sliceFrom","sliceUntil","total")){ + var p=ready();String old=SelfopStageEstimate.identity(p); + Object value=p.get(key);p.put(key,value instanceof Number?((Number)value).longValue()+1:"changed"); + assertNotEquals(old,SelfopStageEstimate.identity(p));assertEquals("SAMPLING",estimate(p,61).unavailableReason()); + commit(p,61,62,true);assertEquals("SAMPLING",estimate(p,61).unavailableReason()); + } + } + @Test void pausedUnhealthyTerminalUnknownAndCompleteNeverReturnActiveNumbers(){ + var p=ready(); + var paused=SelfopStageEstimate.estimate(p,start.plusSeconds(60),true,true,false);assertEquals("PAUSED",paused.status());assertNull(paused.ratePerSecond()); + var terminal=SelfopStageEstimate.estimate(p,start.plusSeconds(60),true,true,true);assertEquals("UNAVAILABLE",terminal.status());assertEquals("TERMINAL",terminal.unavailableReason()); + assertEquals("HEALTH_UNCONFIRMED",SelfopStageEstimate.estimate(p,start.plusSeconds(60),false,false,false).unavailableReason()); + p.put("totalKnown",false);assertEquals("TOTAL_UNKNOWN",estimate(p,60).unavailableReason()); + p.put("totalKnown",true);p.put("completed",1000L);assertEquals("COUNT_COMPLETE",estimate(p,60).unavailableReason()); + commit(p,61,1000,true);assertFalse(p.containsKey("etaSamples")); + } + @Test void zeroAndFullCountsNeverInferStageCompletionOrPublication(){ + for(long total:List.of(0L,1000L)){ + var p=progress();p.put("total",total);p.put("completed",total);p.put("stageFinishedAt",null); + var result=estimate(p,60); + assertEquals("UNAVAILABLE",result.status());assertEquals("COUNT_COMPLETE",result.unavailableReason()); + assertNull(result.remainingSeconds());assertNull(result.ratePerSecond());assertNull(result.validUntil()); + assertNull(p.get("stageFinishedAt"));assertFalse(p.containsKey("publishedAt")); + assertEquals("DETAIL",p.get("phase")); + } + } + @Test void bucketBoundaryHighFrequencyAndLongHistoryStayBounded(){ + var p=progress();p.put("total",100000L); + for(int second=0;second<=1000;second++)commit(p,second,second,true); + var sample=(Map)p.get("etaSamples");var points=(List)sample.get("p");assertTrue(points.size()<=20); + assertTrue(json(sample).getBytes(StandardCharsets.UTF_8).length<=1024);assertEquals("AVAILABLE",estimate(p,1000).status()); + long first=((Number)((List)points.get(0)).get(0)).longValue();assertTrue(start.plusSeconds(1000).getEpochSecond()-first<=180); + var boundary=progress();commit(boundary,9,1,true);commit(boundary,10,2,true); + assertEquals(2,((List)((Map)boundary.get("etaSamples")).get("p")).size()); + } + @Test void sixtySecondGapAndCounterRegressionRestartSampling(){ + var p=ready();commit(p,120,62,true);assertEquals("SAMPLING",estimate(p,120).unavailableReason()); + commit(p,140,1,true);assertEquals("SAMPLING",estimate(p,140).unavailableReason()); + } + @Test void corruptFractionalOutOfOrderOrOversizedSamplesFailClosed(){ + var p=ready();p.put("completed",new BigDecimal("61.5"));assertEquals("INVALID_PROGRESS",estimate(p,60).unavailableReason()); + p=ready();var sample=new LinkedHashMap<>((Map)p.get("etaSamples")); + sample.put("p",List.of(List.of(start.getEpochSecond()+40,1L,0L),List.of(start.getEpochSecond()+20,2L,1L)));p.put("etaSamples",sample); + assertEquals("INVALID_SAMPLE",estimate(p,60).unavailableReason()); + p=ready();var future=estimate(p,50);assertEquals("INVALID_PROGRESS",future.unavailableReason()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java index 78f919279..853711e3e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java @@ -25,6 +25,78 @@ class CollectionQueryServiceTest { CollectionQuery q(Integer page,LocalDateTime upper,Long id) { return new CollectionQuery("primary","A",null,null,null,null,null,null,page,null,upper==null?null:upper.toInstant(ZoneOffset.UTC),id); } + private Task activity(long id,String status,Long upstream,boolean account,LocalDateTime created,String progress) { + return new Task(id,"LXCOL-"+id,"primary",account?null:"A","OUTBOUND",account?"ACCOUNT_DISCOVERY":"WAREHOUSE_SYNC","BACKFILL","BUSINESS", + "hash",status,"NOT_STARTED",1,now.minusDays(1),now,null,upstream,null,null,progress,now,null,null,false,created,created); + } + @Test void currentActivityWinsOverNewerHistoryAndIsBoundedToEight() { + var candidates=new ArrayList(); + candidates.add(activity(99,"SUCCEEDED",null,false,now,null)); + for(int i=0;i<10;i++)candidates.add(activity(10+i,"WAITING_SOURCE",null,false,now.minusMinutes(i+1),null)); + candidates.add(activity(1,"RUNNING",null,false,now.minusDays(1),null)); + when(mapper.currentTasks(eq("primary"),anyList())).thenReturn(candidates); + var row=service.overview(q(null,null,null)).warehouses().get(0); + assertEquals("LXCOL-1",row.currentTask().taskNo());assertEquals(8,row.activeTasks().size()); + assertEquals("LXCOL-1",row.activeTasks().get(0).taskNo()); + assertTrue(row.activeTasks().stream().noneMatch(t->"SUCCEEDED".equals(t.taskStatus()))); + } + @Test void overlappingAccountActivityDoesNotProveWarehouseDependency() { + var child=activity(7,"WAITING_SOURCE",null,false,now,null); + var account=activity(8,"RUNNING",null,true,now,null); + when(mapper.currentTasks(eq("primary"),anyList())).thenReturn(List.of(child)); + when(mapper.sharedActiveAccount("primary")).thenReturn(account); + when(mapper.task("primary","A",child.taskNo())).thenReturn(child); + var result=service.overview(q(null,null,null));var row=result.warehouses().get(0); + assertEquals(account.taskNo(),result.accountActivity().taskNo());assertNull(row.sharedUpstream()); + assertEquals("WAITING_UNCONFIRMED",row.currentTask().dependencyState()); + var detail=service.taskDetail(child.taskNo(),q(null,null,null)); + assertNull(detail.sharedUpstream());assertEquals(row.currentTask().dependencyState(),detail.task().dependencyState()); + } + @Test void directDependenciesAreConsistentInOverviewAndDetailIncludingFailureAndCompletion() { + enableBusiness("production"); + var child=activity(7,"WAITING_SOURCE",8L,false,now,null); + when(mapper.currentTasks(eq("primary"),anyList())).thenReturn(List.of(child)); + when(mapper.task("primary","A",child.taskNo())).thenReturn(child); + for(var pair:Map.of("RUNNING","WAITING_CONFIRMED","SUCCEEDED","SATISFIED","DEAD","FAILED").entrySet()) { + var parent=activity(8,pair.getKey(),null,true,now.minusHours(1),null); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(parent)); + var row=service.overview(q(null,null,null)).warehouses().get(0); + var detail=service.taskDetail(child.taskNo(),q(null,null,null)); + assertEquals(parent.taskNo(),row.sharedUpstream().taskNo()); + assertEquals(parent.taskNo(),detail.sharedUpstream().taskNo()); + assertEquals(pair.getValue(),row.currentTask().dependencyState()); + assertEquals(pair.getValue(),detail.task().dependencyState()); + if("RUNNING".equals(pair.getKey())) { + assertEquals(parent.taskNo(),detail.task().progressSourceTaskNo()); + assertEquals("UPSTREAM_NOT_READY",detail.task().blockingReason()); + assertEquals("TASK_NOT_RETRYABLE",detail.task().actionBlockReason()); + } + } + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of()); + assertEquals("WAITING_UNCONFIRMED",service.taskDetail(child.taskNo(),q(null,null,null)).task().dependencyState()); + } + @Test void stageEstimateUsesCommittedSamplesWithoutExposingThemAndRejectsAttemptMismatch() throws Exception { + enableBusiness("production"); + var p=new LinkedHashMap(); + p.put("taskNo","LXCOL-7");p.put("attemptNo",1);p.put("phase","DETAIL");p.put("scanRound",1); + p.put("progressScope","WAREHOUSE");p.put("unit","ORDER");p.put("countBasis","TASK_CUMULATIVE");p.put("total",100);p.put("totalKnown",true); + Instant at=now.toInstant(ZoneOffset.UTC); + for(int i=0;i<4;i++) { + p.put("completed",10+i*10); + com.baiyue.lastleg.selfoperated.analytics.model.SelfopStageEstimate.checkpoint(p,at.minusSeconds(70-i*20),true); + } + var t=activity(7,"RUNNING",null,false,now.minusMinutes(2),new ObjectMapper().writeValueAsString(p)); + when(mapper.task("primary","A",t.taskNo())).thenReturn(t); + when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FETCHING",null))); + var result=service.taskDetail(t.taskNo(),q(null,null,null)).task(); + assertEquals("AVAILABLE",result.stageEstimate().status()); + assertEquals(result.progress().get("completed"),result.stageEstimate().completed()); + assertEquals(result.progress().get("total"),result.stageEstimate().total()); + assertFalse(result.progress().containsKey("etaSamples")); + when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(2,"FETCHING",null))); + result=service.taskDetail(t.taskNo(),q(null,null,null)).task(); + assertEquals("UNAVAILABLE",result.stageEstimate().status());assertNull(result.stageEstimate().remainingSeconds()); + } @Test void controlledTestBusinessProjectsEnabledInventoryPublicationWithoutAutomaticSchedule() { config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setQuantityUnit("PCS"); config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true); @@ -279,7 +351,7 @@ class CollectionQueryServiceTest { "publishedRuns","currentTasks","latestValidationTasks","lastSuccessfulTasks","sharedActiveAccount","barrierWarehouses", "inventoryDailySamples","inventoryAccumulationStarts")) { String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql(); - assertFalse(sql.contains("AS routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot")); + assertFalse(sql.contains("routing_manifest_json"));assertFalse(sql.contains("JSON_TABLE"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot")); assertFalse(sql.contains("ll_selfop_sync_stage")); } String runSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".runs").getBoundSql(parameters).getSql(); @@ -289,11 +361,15 @@ class CollectionQueryServiceTest { assertTrue(sql.contains("b.source_account=t.source_account")); assertTrue(sql.contains("b.execution_purpose='BUSINESS' AND b.business_root_task_id=b.id")); assertTrue(sql.contains("AS business_local_work_ready")); - assertTrue(sql.contains("'$.objects'))='OBJECT'")); - assertTrue(sql.contains("'$.objects.*'")); - assertTrue(sql.contains("COALESCE(JSON_TYPE(JSON_EXTRACT(manifest_item.entry,'$.effectiveStageId')),'NULL')")); - assertTrue(sql.contains("BETWEEN 1 AND 9223372036854775807) IS NOT TRUE")); + assertTrue(sql.contains("OCTET_LENGTH(t.progress_json) BETWEEN 0 AND 8192")); + assertTrue(sql.contains("'$.localWorkReady')=CAST('true' AS JSON)")); + assertTrue(sql.contains("'$.localWorkReadyScopeHash'))=t.scope_hash")); + assertTrue(sql.contains("'$.localWorkReadyUpstreamTaskId'))=CAST(t.upstream_task_id AS CHAR)")); } + String currentSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".currentTasks").getBoundSql(parameters).getSql(); + assertTrue(currentSql.contains("ranked.rn <= 8")); + assertTrue(currentSql.contains("CASE WHEN status='RUNNING' THEN 0")); + assertTrue(currentSql.contains("source_account=? AND task_role='WAREHOUSE_SYNC'")); assertTrue(runSql.contains("r.created_at <"));assertTrue(runSql.contains("r.id <="));assertTrue(runSql.contains("LIMIT ? OFFSET ?")); String samplesSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".inventoryDailySamples").getBoundSql(parameters).getSql(); assertTrue(samplesSql.contains("r.inventory_daily_sample_date >= ?")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index 1dd0d91da..e79ee25fa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -225,6 +225,10 @@ class SelfopEgressServiceTest { assertThat(tasks).contains("business_root_task_id=? AND source_account=? AND execution_purpose='BUSINESS'", "next_retry_at_utc=NULL", "status IN ('WAITING_SOURCE','READY','RUNNING','RETRY_WAIT')"); assertThat(runs).contains("business_root_task_id=? AND source_account=? AND execution_purpose='BUSINESS'", "error_code=?", "status IN ('FETCHING','VALIDATED','YIELDED','READY','BUILDING')"); assertThat(tasks).doesNotContain("SUCCEEDED");assertThat(runs).doesNotContain("PUBLISHED","REUSED"); + assertThat(runs).contains("JSON_SET(JSON_REMOVE", "'$.etaSamples'", "'$.summaryAsOf'", "DATE_FORMAT(?,", "JSON_EXTRACT(progress_json,'$.attemptNo')=attempt_no"); + args.put("status","FAILED"); + String validation=configuration.getMappedStatement(prefix+"terminateValidationRuns").getBoundSql(args).getSql(); + assertThat(validation).contains("'$.etaSamples'", "'$.summaryAsOf'", "DATE_FORMAT(?,", "validation_root_task_id=?"); } @Test void committedCompletionWithLostReplyStaysPausedAcrossIntegrationRestart(){ business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java index 84823e787..b971ca49e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java @@ -167,6 +167,8 @@ class SelfopDiscoveryServiceTest { service.collect(task,run,lease); var warehouseA=children.get("route:1:A");var warehouseB=children.get("route:1:B"); assertEquals("WAITING_SOURCE",warehouseA.getStatus());assertEquals("WAITING_SOURCE",warehouseB.getStatus()); + assertEquals(false,read(warehouseA.getProgressJson(),Map.class).get("localWorkReady")); + assertEquals(false,read(warehouseB.getProgressJson(),Map.class).get("localWorkReady")); assertTrue(read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().isEmpty()); var partial=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); assertTrue(partial.isDirectoryComplete());assertEquals(1,partial.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count()); @@ -175,6 +177,11 @@ class SelfopDiscoveryServiceTest { account.setRequestBudget(10);service.collect(task,run,lease); assertEquals("READY",warehouseA.getStatus());assertEquals("READY",warehouseB.getStatus()); + for(var child:List.of(warehouseA,warehouseB)){ + var proof=read(child.getProgressJson(),Map.class);assertEquals(true,proof.get("localWorkReady")); + assertEquals(child.getScopeHash(),proof.get("localWorkReadyScopeHash")); + assertEquals(task.getId().toString(),proof.get("localWorkReadyUpstreamTaskId")); + } var destinationManifest=read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class); String movedKey=hash(List.of("demo","customer","two"));assertEquals(Set.of(movedKey),destinationManifest.getObjects().keySet()); var moved=destinationManifest.getObjects().get(movedKey);assertNotNull(moved.getEffectiveStageId());assertEquals("B",moved.getWarehouseCode()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java index c9b0e42d6..3920dea7b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java @@ -69,6 +69,27 @@ class SelfopInventoryRecoveryTest { assertEquals(7L, resumed.getFenceToken()); assertEquals("FETCHING", resumed.getStatus()); assertNull(resumed.getFinishedAtUtc()); verify(runs, never()).insert(any(SelfopSyncRun.class)); } + @Test void resumedInventoryReplacesYieldSnapshotWithFinalProgressWithoutChangingHistoricalAttempt(){ + original.setFenceToken(lease.fence()); + SelfopProgressProjection.applyCheckpoint(task,original,"INVENTORY","PAGE",1,4L,1); + service.yield(1L,20L,lease); + var yielded=read(original.getProgressSnapshotJson(),java.util.Map.class); + assertEquals(1,yielded.get("completed"));assertNotNull(yielded.get("summaryAsOf")); + var history=new SelfopSyncRun();history.setId(19L);history.setAttemptNo(0); + String historical="{\"attemptNo\":0,\"completed\":9,\"summaryAsOf\":\"2026-10-12T00:00:00Z\"}"; + history.setProgressSnapshotJson(historical); + when(runs.selectOne(any())).thenReturn(null,original); + var resumed=service.begin(1L,lease);assertSame(original,resumed);assertNull(resumed.getProgressSnapshotJson()); + var update=ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(runs).update(isNull(),update.capture()); + assertTrue(((com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper)update.getValue()).getSqlSet().contains("progress_snapshot_json")); + SelfopProgressProjection.applyCheckpoint(task,resumed,"INVENTORY","PAGE",4,4L,1); + java.time.Instant before=java.time.Instant.now();SelfopProgressProjection.freezeRun(task,resumed); + java.time.Instant after=java.time.Instant.now();var finished=read(resumed.getProgressSnapshotJson(),java.util.Map.class); + assertEquals(4,finished.get("completed"));var asOf=java.time.Instant.parse((String)finished.get("summaryAsOf")); + assertFalse(asOf.isBefore(before));assertFalse(asOf.isAfter(after));assertNotEquals(yielded,finished); + SelfopProgressProjection.freezeRun(task,history);assertEquals(historical,history.getProgressSnapshotJson()); + } @Test void genuineFailureClearsCursorAndNewAttemptStartsAtPageOneWithItsOwnStageBatch() { original.setFenceToken(lease.fence()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java index fdb0608a9..2f4bbadd8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java @@ -62,4 +62,57 @@ class SelfopProgressServiceTest { verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class)); var order=inOrder(leases,locks);order.verify(leases).verify(lease);order.verify(locks).lockTask(1);order.verify(locks).lockRun(2); } + @Test void terminalSnapshotHasActualFreezeTimeAndResumeCannotKeepPausedEtaBaseline(){ + var t=task();var r=run();SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,10L,1); + assertTrue(read(t.getProgressJson(),Map.class).containsKey("etaSamples")); + Instant before=Instant.now();SelfopProgressProjection.freezeRun(t,r);Instant after=Instant.now(); + var snapshot=read(r.getProgressSnapshotJson(),Map.class);Instant asOf=Instant.parse((String)snapshot.get("summaryAsOf")); + assertFalse(asOf.isBefore(before));assertFalse(asOf.isAfter(after));assertFalse(snapshot.containsKey("etaSamples")); + assertFalse(read(t.getProgressJson(),Map.class).containsKey("etaSamples")); + SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",5,10L,1); + var points=(List)((Map)read(t.getProgressJson(),Map.class).get("etaSamples")).get("p");assertEquals(1,points.size()); + var fallback=run();SelfopProgressProjection.freezeRun(task(),fallback); + assertNotNull(read(fallback.getProgressSnapshotJson(),Map.class).get("summaryAsOf")); + } + @Test void etaIsFirstToBeDroppedAtEightKiBWhileCandidateAndAuditEvidenceRemain(){ + var t=task();var r=run();SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",1,100L,1); + var p=new java.util.LinkedHashMap(read(t.getProgressJson(),Map.class));p.remove("etaSamples"); + p.put("candidateBuild",Map.of("committedBatches",7)); + p.put("audit", "x".repeat(Math.max(0,8192-json(p).getBytes(StandardCharsets.UTF_8).length-80))); + t.setProgressJson(json(p));SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",2,100L,1); + var result=read(t.getProgressJson(),Map.class);assertFalse(result.containsKey("etaSamples")); + assertEquals(Map.of("committedBatches",7),result.get("candidateBuild"));assertEquals(p.get("audit"),result.get("audit")); + assertTrue(t.getProgressJson().getBytes(StandardCharsets.UTF_8).length<=8192); + } + @Test void localWorkProofIsBoundToTheCommittedScopeAndUpstream(){ + var t=task();t.setScopeHash("scope-1");t.setUpstreamTaskId(99L); + SelfopProgressProjection.localWorkReady(t,true);var p=read(t.getProgressJson(),Map.class); + assertEquals(true,p.get("localWorkReady"));assertEquals("scope-1",p.get("localWorkReadyScopeHash"));assertEquals("99",p.get("localWorkReadyUpstreamTaskId")); + SelfopProgressProjection.applyCheckpoint(t,run(),"MERGE","ORDER",0,4L,0); + assertEquals(true,read(t.getProgressJson(),Map.class).get("localWorkReady")); + var next=run();next.setAttemptNo(2);SelfopProgressProjection.applyCheckpoint(t,next,"MERGE","ORDER",1,4L,0); + assertEquals(true,read(t.getProgressJson(),Map.class).get("localWorkReady")); + t.setScopeHash("scope-2");next.setAttemptNo(3);SelfopProgressProjection.applyCheckpoint(t,next,"MERGE","ORDER",1,4L,0); + assertFalse(read(t.getProgressJson(),Map.class).containsKey("localWorkReady")); + SelfopProgressProjection.localWorkReady(t,false);assertEquals(false,read(t.getProgressJson(),Map.class).get("localWorkReady")); + } + @Test void exactLegacyEightKiBCheckpointFreezesWithoutLosingBusinessOrAuditCounts(){ + var t=task();var r=run();SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,10L,1); + var p=new java.util.LinkedHashMap(read(t.getProgressJson(),Map.class));p.remove("etaSamples"); + p.put("candidateBuild",Map.of("committedBatches",7));p.put("audit",""); + p.put("audit","x".repeat(8192-json(p).getBytes(StandardCharsets.UTF_8).length));t.setProgressJson(json(p)); + assertEquals(8192,t.getProgressJson().getBytes(StandardCharsets.UTF_8).length); + assertDoesNotThrow(()->SelfopProgressProjection.freezeRun(t,r));var frozen=read(r.getProgressSnapshotJson(),Map.class); + assertNotNull(frozen.get("summaryAsOf"));assertEquals(4,frozen.get("completed"));assertEquals(10,frozen.get("total")); + assertEquals(p.get("candidateBuild"),frozen.get("candidateBuild"));assertEquals(p.get("audit"),frozen.get("audit")); + assertTrue(r.getProgressSnapshotJson().getBytes(StandardCharsets.UTF_8).length<=8192); + var stage=(Map)((List)frozen.get("stages")).get(0);assertEquals(4,stage.get("completed"));assertEquals(10,stage.get("total")); + } + @Test void minimalFullSizeLegacyCheckpointNeverFailsBusinessEndForOptionalTimestamp(){ + var t=task();var r=run();var p=new java.util.LinkedHashMap(); + p.put("attemptNo",1);p.put("completed",4);p.put("total",10);p.put("audit",""); + p.put("audit","x".repeat(8192-json(p).getBytes(StandardCharsets.UTF_8).length));t.setProgressJson(json(p)); + assertDoesNotThrow(()->SelfopProgressProjection.freezeRun(t,r));var frozen=read(r.getProgressSnapshotJson(),Map.class); + assertEquals(p,frozen);assertEquals(8192,r.getProgressSnapshotJson().getBytes(StandardCharsets.UTF_8).length); + } } From c07131b26fc102a70e9a36e245eadbbeee6e6c5f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 13 Oct 2026 17:42:54 +0800 Subject: [PATCH 51/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- .../lastleg/mapper/LastlegOrderMapper.java | 2 +- .../lastleg/relabel/dto/RelabelResponses.java | 2 +- .../service/RelabelApplicationService.java | 4 +- .../service/ReturnReceiptService.java | 2 +- .../RelabelApplicationServiceTest.java | 6 ++- .../service/ReturnReceiptServiceTest.java | 19 ++++++++++ .../service/LastlegOrderReturnVolumeTest.java | 37 +++++++++++++++++++ 7 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderReturnVolumeTest.java 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 c0d87a9c8..519aee690 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 @@ -84,7 +84,7 @@ public interface LastlegOrderMapper extends BaseMapper { "ORDER BY r.signed_at DESC,r.id DESC) AS measurementNo " + "FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id AND o.customer_id=r.customer_id " + "WHERE o.lastleg_order_id IN #{id} " + - "AND r.unit_type IN ('PALLET','BOX') AND r.length IS NOT NULL AND r.width IS NOT NULL AND r.height IS NOT NULL" + + "AND r.unit_type IN ('PALLET','BOX','PARCEL') AND r.length IS NOT NULL AND r.width IS NOT NULL AND r.height IS NOT NULL" + ") m WHERE m.measurementNo=1 GROUP BY m.orderId") List findReturnVolumes(@Param("orderIds") List orderIds); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 462e0ce41..de4e8e220 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -20,7 +20,7 @@ public final class RelabelResponses { String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, String reviewChannel, String reviewRemark, LocalDateTime createdAt, LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, - String productNameZh, String labelRequirement) {} + String productNameZh, String labelRequirement, List operationTypes) {} public record Milestones(LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, 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 8ff831431..56c9825dc 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 @@ -1226,7 +1226,9 @@ public class RelabelApplicationService implements WalletResultHandler { detailItems.stream().map(RelabelItem::getProductNameZh).filter(StringUtils::hasText) .map(String::trim).distinct().collect(Collectors.joining("、")), detailItems.stream().map(RelabelItem::getLabelRequirement).filter(StringUtils::hasText) - .map(String::trim).distinct().collect(Collectors.joining(";"))); + .map(String::trim).distinct().collect(Collectors.joining(";")), + detailItems.stream().map(RelabelItem::getOperationType).filter(StringUtils::hasText) + .map(String::trim).distinct().toList()); } @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } 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 fe3dd0832..5c614af16 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 @@ -768,7 +768,7 @@ public class ReturnReceiptService { tail.setPackageCount(actualBoxes); tail.setReceivedPackageCount(actualBoxes); tail.setProductQuantity(actualUnits); tail.setPalletCount(palletCodes.size()); for (ReturnReceipt signed : signedReceipts) { - if (signed.getLength() == null || signed.getWidth() == null || signed.getHeight() == null || "PARCEL".equals(signed.getUnitType())) continue; + if (signed.getLength() == null || signed.getWidth() == null || signed.getHeight() == null) continue; String code = text(signed.getUnitCode()) == null ? signed.getReceiptNo() : signed.getUnitCode(); measuredUnits.putIfAbsent(signed.getUnitType() + ":" + code, signed); } 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 2dea60a16..1f762d90b 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 @@ -75,10 +75,11 @@ class RelabelApplicationServiceTest { when(applications.findMilestones("10001", 42L)) .thenReturn(new RelabelResponses.Milestones(reviewed, completed, outbound)); var first = new RelabelItem(); first.setProductNameZh(" 商品甲 "); first.setLabelRequirement(" 贴标要求111 "); - first.setSkuCode("SKU-FIRST"); + first.setSkuCode("SKU-FIRST"); first.setOperationType("SKU_LABEL"); first.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU-FIRST\",\"newSkuCode\":\"NEW-FIRST\",\"quantity\":10},{\"skuCode\":\"SKU-SECOND\",\"newSkuCode\":\"NEW-SECOND\",\"quantity\":5}]"); var second = new RelabelItem(); second.setProductNameZh("商品乙"); second.setLabelRequirement("贴标要求111"); - var empty = new RelabelItem(); empty.setProductNameZh(" "); + second.setOperationType("CARTON_LABEL"); + var empty = new RelabelItem(); empty.setProductNameZh(" "); empty.setOperationType("SKU_LABEL"); RelabelResponses.Summary result = ReflectionTestUtils.invokeMethod(service, "summary", application, List.of(first, second, empty)); assertNotNull(result); assertEquals(reviewed, result.reviewedAt()); @@ -87,6 +88,7 @@ class RelabelApplicationServiceTest { assertEquals("商品甲、商品乙", result.productNameZh()); assertEquals("贴标要求111", result.labelRequirement()); assertEquals("SKU-FIRST,SKU-SECOND", result.skuCodes()); + assertEquals(List.of("SKU_LABEL", "CARTON_LABEL"), result.operationTypes()); assertEquals(3, result.itemCount()); } 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 c77c1f9fc..55c7a3b05 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 @@ -82,6 +82,25 @@ class ReturnReceiptServiceTest { verify(details, times(1)).insert(any(com.baiyue.lastleg.entity.LastlegOrderDetail.class)); verify(details, times(1)).updateById(any(com.baiyue.lastleg.entity.LastlegOrderDetail.class)); } + @Test void countedParcelsContributeVolumeOncePerPhysicalPackage() { + ReturnOrder order = new ReturnOrder(); order.setId(1L); order.setCustomerId("C1"); order.setLastlegOrderId(9L); + ReturnOrderItem sku = new ReturnOrderItem(); sku.setSkuCode("SKU1"); + ReturnReceipt first = measuredReceipt(2L, "PK-A", "50", "40", "40"); first.setReturnOrderId(1L); first.setUnitType("PARCEL"); + ReturnReceipt second = measuredReceipt(3L, "PK-B", "60", "50", "50"); second.setUnitType("PARCEL"); + ReturnReceipt earlier = measuredReceipt(4L, "PK-A", "40", "40", "40"); earlier.setUnitType("PARCEL"); + var counted = new com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem(); counted.setSkuCode("SKU1"); counted.setReceivedQuantity(24L); + LastlegOrder tail = new LastlegOrder(); tail.setId(9L); tail.setCustomerId("C1"); + when(orders.selectById(1L)).thenReturn(order); when(orderItems.listOrder(1L)).thenReturn(List.of(sku)); + when(lastlegOrders.selectById(9L)).thenReturn(tail); when(receipts.listOrder(1L)).thenReturn(List.of(second, first, earlier)); + when(receiptItems.listReceipt(2L)).thenReturn(List.of(counted)); + + service.refreshCountedTailCargo(first); + + assertThat(tail.getVolumeCbm()).isEqualByComparingTo("0.23"); + assertEquals(0, tail.getPackageCount()); + assertEquals(0, tail.getPalletCount()); + verify(lastlegOrders).updateById(tail); + } @Mock ReturnOrderMapper orders; @Mock ReturnOrderItemMapper orderItems; @Mock ReturnReceiptMapper receipts; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderReturnVolumeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderReturnVolumeTest.java new file mode 100644 index 000000000..ed78ca1cb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderReturnVolumeTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegOrderReturnVolumeTest { + @Test void historicalParcelsAreIncludedWithLatestCarrierMeasurementsAndCustomerIsolation() throws Exception { + var config = new MybatisConfiguration(); + config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), + new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:return-volume;MODE=MySQL", "sa", ""))); + config.addMapper(LastlegOrderMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(config).openSession()) { + var sql = session.getConnection().createStatement(); + sql.execute("CREATE TABLE ll_return_order (id BIGINT, customer_id VARCHAR, lastleg_order_id BIGINT)"); + sql.execute("CREATE TABLE ll_return_receipt (id BIGINT, return_order_id BIGINT, customer_id VARCHAR, unit_type VARCHAR, unit_code VARCHAR, receipt_no VARCHAR, signed_at TIMESTAMP, length DECIMAL(12,3), width DECIMAL(12,3), height DECIMAL(12,3))"); + sql.execute("INSERT INTO ll_return_order VALUES (1,'A',9),(2,'B',10)"); + sql.execute("INSERT INTO ll_return_receipt VALUES " + + "(1,1,'A','PARCEL','PK-A','RR-1','2026-10-13 10:00:00',40,40,40)," + + "(2,1,'A','PARCEL','PK-A','RR-2','2026-10-13 11:00:00',50,40,40)," + + "(3,1,'A','PARCEL','PK-B','RR-3','2026-10-13 11:00:00',60,50,50)," + + "(4,1,'B','PARCEL','PK-C','RR-4','2026-10-13 11:00:00',100,100,100)," + + "(5,2,'B','PARCEL','PK-D','RR-5','2026-10-13 11:00:00',100,100,100)," + + "(6,1,'A','PARCEL','PK-E','RR-6','2026-10-13 11:00:00',NULL,50,50)"); + var rows = session.getMapper(LastlegOrderMapper.class).findReturnVolumes(List.of(9L)); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).orderId()).isEqualTo(9L); + assertThat(rows.get(0).volumeCbm()).isEqualByComparingTo("0.23"); + } + } +} From 38958c891ae73aa9931f621d937438bcf90b187a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 13 Oct 2026 18:05:30 +0800 Subject: [PATCH 52/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- .../lastleg/relabel/dto/RelabelResponses.java | 3 ++- .../service/RelabelApplicationService.java | 17 +++++++++++++- .../RelabelApplicationServiceTest.java | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index de4e8e220..1ba2d252d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -20,7 +20,8 @@ public final class RelabelResponses { String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt, String reviewChannel, String reviewRemark, LocalDateTime createdAt, LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, - String productNameZh, String labelRequirement, List operationTypes) {} + String productNameZh, String labelRequirement, List operationTypes, + List relatedBusinessOrderNos) {} public record Milestones(LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, 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 56c9825dc..122601547 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 @@ -1228,7 +1228,22 @@ public class RelabelApplicationService implements WalletResultHandler { detailItems.stream().map(RelabelItem::getLabelRequirement).filter(StringUtils::hasText) .map(String::trim).distinct().collect(Collectors.joining(";")), detailItems.stream().map(RelabelItem::getOperationType).filter(StringUtils::hasText) - .map(String::trim).distinct().toList()); + .map(String::trim).distinct().toList(), relatedBusinessOrderNos(value, order, detailItems)); + } + + private List relatedBusinessOrderNos(RelabelApplication application, LastlegOrder order, + List detailItems) { + if (order != null && Objects.equals(application.getCustomerId(), order.getCustomerId()) + && !"RELABEL_APPLICATION".equals(order.getOrderType())) { + String businessNo = StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo(); + if (StringUtils.hasText(businessNo)) return List.of(businessNo.trim()); + } + // Standalone relabel orders keep the actual inventory source on their item snapshots. + // Their tail sourceOrderNo is a customer reference, not a business order number. + return detailItems.stream().map(item -> StringUtils.hasText(item.getSourceInboundOrderNo()) + ? item.getSourceInboundOrderNo() + : "RETURN_BULK_SKU_LABEL".equals(item.getOperationType()) ? item.getShipmentNo() : null) + .filter(StringUtils::hasText).map(String::trim).distinct().toList(); } @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } 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 1f762d90b..f69a4d46a 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 @@ -66,6 +66,29 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class RelabelApplicationServiceTest { + @Test void relatedBusinessNumbersUseAuthoritativeSourcesInsteadOfCustomerReferences() { + var application = new RelabelApplication(); application.setCustomerId("C1"); application.setCustomerOrderNo("CUSTOMER-REFERENCE"); + var order = new LastlegOrder(); order.setCustomerId("C1"); order.setOrderNo("TAIL-INTERNAL-NO"); + for (var type : List.of("WAREHOUSE_TRANSFER", "RETURN_RELABEL", "FIRST_LEG")) { + order.setOrderType(type); order.setSourceOrderNo(type + "-BUSINESS-NO"); + List numbers = ReflectionTestUtils.invokeMethod(service, "relatedBusinessOrderNos", application, order, List.of()); + assertEquals(List.of(type + "-BUSINESS-NO"), numbers); + } + order.setOrderType("RELABEL_APPLICATION"); order.setSourceOrderNo("CUSTOMER-REFERENCE"); + var first = new RelabelItem(); first.setSourceInboundOrderNo(" RT-SOURCE-1 "); + var duplicate = new RelabelItem(); duplicate.setSourceInboundOrderNo("RT-SOURCE-1"); + var second = new RelabelItem(); second.setSourceInboundOrderNo("IN-SOURCE-2"); + var bulkReturn = new RelabelItem(); bulkReturn.setOperationType("RETURN_BULK_SKU_LABEL"); bulkReturn.setShipmentNo("RT-SOURCE-3"); + var shipment = new RelabelItem(); shipment.setOperationType("CARTON_LABEL"); shipment.setShipmentNo("FBA-SHIPMENT"); + List sources = ReflectionTestUtils.invokeMethod(service, "relatedBusinessOrderNos", application, order, + List.of(first, duplicate, second, bulkReturn, shipment)); + assertEquals(List.of("RT-SOURCE-1", "IN-SOURCE-2", "RT-SOURCE-3"), sources); + List missing = ReflectionTestUtils.invokeMethod(service, "relatedBusinessOrderNos", application, order, List.of(shipment)); + assertEquals(List.of(), missing); + order.setOrderType("RETURN_RELABEL"); order.setCustomerId("OTHER"); order.setSourceOrderNo("RT-OTHER-CUSTOMER"); + List foreign = ReflectionTestUtils.invokeMethod(service, "relatedBusinessOrderNos", application, order, List.of()); + assertEquals(List.of(), foreign); + } @Test void summaryReturnsMilestonesFromCustomerScopedFacts() { var application = new RelabelApplication(); application.setId(42L); application.setCustomerId("10001"); From f151e74f5c0dd7b3d5fd7b2028fa4f976e0968cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 18:44:49 +0800 Subject: [PATCH 53/80] fix(self-operated): rank hot SKUs for selected dates --- .../query/AnalyticsInventorySql.java | 9 ++++--- .../analytics/query/AnalyticsQuery.java | 2 ++ .../query/AnalyticsQueryService.java | 2 +- .../analytics/query/AnalyticsReadSql.java | 16 +++++------ .../query/DashboardQueryService.java | 27 +++++++++++++++---- .../analytics/query/DashboardReadSql.java | 4 +++ .../analytics/query/DashboardResponses.java | 2 +- .../openapi/self-operated-analytics-v1.yaml | 15 ++++++----- .../query/AnalyticsQueryServiceTest.java | 8 ++++++ .../query/AnalyticsReadMapperTest.java | 5 ++-- .../query/DashboardQueryServiceTest.java | 3 ++- .../query/DashboardSqlSemanticTest.java | 15 +++++++++++ 12 files changed, 80 insertions(+), 28 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java index ad8ade2ea..a96458476 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -20,8 +20,8 @@ public final class AnalyticsInventorySql { static String dashboardCte(AnalyticsSqlRequest p){return cte(p,false,true);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst){return cte(p,stockFirst,false);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst,boolean dashboard){ - String days="INVENTORY".equals(p.kind())?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30"; - String complete="INVENTORY".equals(p.kind())?"#{p.completeWindow}":"#{p.complete30}"; + String days=("INVENTORY".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30"; + String complete=("INVENTORY".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?"#{p.completeWindow}":"#{p.complete30}"; return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," @@ -36,11 +36,12 @@ public final class AnalyticsInventorySql { +"FROM ll_selfop_inventory_snapshot WHERE "+(dashboard&&!p.stockFresh()?"1=0 /* dashboard unused stock */ AND ":"") +"snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code)," +(stockFirst?stockPageKeys():"") - +salesCte(p,stockFirst,dashboard&&!p.complete30()) + +salesCte(p,stockFirst,dashboard&&!"HOT_DASHBOARD".equals(p.kind())&&!p.complete30()) +" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid," +"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc," +stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc," - +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND "+complete+"=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound," + +("HOT_DASHBOARD".equals(p.kind())?"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN CASE WHEN #{p.completeWindow}=1 THEN COALESCE(s.observed_quantity,0) ELSE s.observed_quantity END END owner_outbound,": + "CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND "+complete+"=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound,") +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count " +"FROM profiles c "+(stockFirst?"JOIN stock_paged pk ON pk.customer_code=c.customer_code AND pk.sku_code=c.sku_code ":"") +"LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code)," diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java index 31d40d586..361e376ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java @@ -30,6 +30,8 @@ public record AnalyticsQuery(String sourceAccount, String warehouseCode, String if(chart){ if(windowDays!=null)throw new BizException("SKU周期查询不接受windowDays"); if(bucket!=null&&!"DAY".equals(bucket))throw new BizException("仅支持DAY日粒度"); + }else if(hot&&"CUSTOM".equals(rangePreset)){ + if(startDate==null||endDate==null||windowDays!=null||bucket!=null)throw new BizException("自定义热销窗口须提供起止日期,不接受windowDays或bucket"); }else{ if(rangePreset!=null||startDate!=null||endDate!=null||bucket!=null)throw new BizException("指标不接受图表周期参数"); if(hot?(rankingDays()!=7&&rankingDays()!=30):(windowDays!=null))throw new BizException("热销仅支持7/30天;其他指标固定30天"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java index 04ecd05e1..80a10caac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java @@ -199,7 +199,7 @@ public class AnalyticsQueryService { LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate(); SalesRangePolicy.Range range; try{ - range=inventoryOptions!=null?InventoryWindowPolicy.range(inventoryOptions,anchor):chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), + range=inventoryOptions!=null?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||(hot&&"CUSTOM".equals(q.rangePreset())))?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays): new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30, anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java index 0fbf1b2e3..61ef17203 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java @@ -209,18 +209,18 @@ public final class AnalyticsReadSql { return "WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER +" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+")," +" metric_sales AS (SELECT customer_code,sku_code," - +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity," - +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders," - +"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," + +"SUM(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN outbound_quantity ELSE 0 END) window_quantity," + +"SUM(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN outbound_order_count ELSE 0 END) window_orders," + +"SUM(CASE WHEN business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," +"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity END) today_quantity," - +"MIN(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) window_valid," - +"MIN(CASE WHEN business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) thirty_valid," + +"MIN(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) window_valid," + +"MIN(CASE WHEN business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) thirty_valid," +"MIN(CASE WHEN business_date=#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) today_valid " +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER - +"AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date=LEAST(#{p.from},TIMESTAMPADD(DAY,-30,#{p.anchor})) AND business_date hotStats=stats;List hotRanked=ranked; + if(!c.rangeStartDate().equals(fixedFrom)||!c.rangeEndDateExclusive().equals(fixedUntil)||!base.complete30()){ + var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),fixedFrom, + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD"); + var hotReport=mapper.report(hotRequest,o).stream().map(DashboardQueryService::normalize).toList(); + hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); + } var salesSummary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),null,null,null,"PCS",trendSummary.outboundQuantity(),trendSummary.observedQuantity(), trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays()); var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList(); @@ -44,10 +51,18 @@ public class DashboardQueryService { Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil); return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,fixedFrom,fixedUntil,30,freshUntil,drilldownUntil),kpis, new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c), - ranking("HOT",stats,ranked,base.salesRun()>0,fixedFrom,fixedUntil,c), + hotRanking(ranking("HOT",hotStats,hotRanked,base.salesRun()>0,c.rangeStartDate(),c.rangeEndDateExclusive(),c),trendSummary.dataStatus()), ranking("LONG",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c), ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c)); } + private static DashboardResponses.Ranking hotRanking(DashboardResponses.Ranking ranking,String windowStatus){ + if("COMPLETE".equals(windowStatus))return ranking; + var items=ranking.items().stream().map(i->new DashboardResponses.Item(i.customerCode(),i.sku(),i.productName(),i.value(),i.unit(),i.windowOutboundQuantity(),i.skuOutboundOrderCount(), + i.physicalQuantity(),i.availableQuantity(),i.frozenQuantity(),i.defectivePhysicalQuantity(),null,null)).toList(); + var reasons=new LinkedHashSet<>(ranking.reasonCodes());reasons.add("SALES_WINDOW_INCOMPLETE"); + return new DashboardResponses.Ranking(ranking.windowStartDate(),ranking.windowEndDateExclusive(),ranking.calculationWindowDays(),ranking.eligibleCount(),ranking.unassessableCount(),items, + items.isEmpty()&&"UNKNOWN".equals(windowStatus)?"UNKNOWN":"PARTIAL",List.copyOf(reasons)); + } private static DashboardResponses.Metric stockMetric(Map s,String knownField,String unknownField,String unit,boolean available,boolean fresh,AnalyticsResponses.Context c){ if(!available)return new DashboardResponses.Metric(null,null,unit,"UNKNOWN",null,List.of("INVENTORY_UNAVAILABLE"),null,null); long unknown=count(s,unknownField);BigDecimal known=decimal(s,knownField); @@ -72,6 +87,7 @@ public class DashboardQueryService { available?count(s,"excluded_zero_skus"):null,List.copyOf(buckets),status,codes,available?Map.copyOf(reasons):Map.of()); } private static DashboardResponses.Ranking ranking(String kind,Map s,List rows,boolean available,LocalDate from,LocalDate until,AnalyticsResponses.Context c){ + int windowDays=(int)java.time.temporal.ChronoUnit.DAYS.between(from,until); String prefix=switch(kind){case "HOT"->"hot";case "LONG"->"long";default->"no_outbound";}; List selected=rows.stream().filter(r->kind.equals(r.rankCode())).sorted(Comparator .comparing(DashboardReadMapper.Ranked::mainValue,Comparator.reverseOrder()) @@ -79,12 +95,12 @@ public class DashboardQueryService { .thenComparing(DashboardReadMapper.Ranked::skuCode).thenComparing(DashboardReadMapper.Ranked::customerCode)).toList(); var items=available?selected.stream().map(r->new DashboardResponses.Item(r.customerCode(),r.skuCode(),r.productName(),r.mainValue(),"LONG".equals(kind)?"DAY":"PCS", "HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.defectivePhysicalQuantity(), - divide30("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d()),r.coverageDays())).toList():List.of(); + divide("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),windowDays),"HOT".equals(kind)?null:r.coverageDays())).toList():List.of(); long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE"; List reasons=!available?List.of("HOT".equals(kind)?"SALES_UNAVAILABLE":"INVENTORY_UNAVAILABLE"):unknown>0?List.of("UNASSESSABLE_SKUS"):List.of(); if(available&&count(s,prefix+"_eligible")==0&&unknown==0)reasons=List.of("NO_ELIGIBLE_SKUS"); - if(available&&"HOT".equals(kind)&&!"VALID".equals(c.salesStatus())){status="UNKNOWN";reasons=List.of("SALES_"+c.salesStatus());} - return new DashboardResponses.Ranking(from,until,30,available?count(s,prefix+"_eligible"):null,available?unknown:null,items,status,reasons); + if(available&&"HOT".equals(kind)&&!Set.of("VALID","STALE").contains(c.salesStatus())){status="UNKNOWN";reasons=List.of("SALES_"+c.salesStatus());} + return new DashboardResponses.Ranking(from,until,windowDays,available?count(s,prefix+"_eligible"):null,available?unknown:null,items,status,reasons); } private static Map normalize(Map source){Map result=new HashMap<>();if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value));return result;} static DashboardReadMapper.Ranked ranked(Map row){ @@ -95,5 +111,6 @@ public class DashboardQueryService { } private static BigDecimal decimal(Map source,String key){Object value=source.get(key);return value==null?null:value instanceof BigDecimal decimal?decimal:new BigDecimal(value.toString());} private static long count(Map source,String key){BigDecimal value=decimal(source,key);return value==null?0:value.longValueExact();} - private static BigDecimal divide30(BigDecimal value){return value==null?null:value.divide(BigDecimal.valueOf(30),12,RoundingMode.HALF_UP);} + private static BigDecimal divide30(BigDecimal value){return divide(value,30);} + private static BigDecimal divide(BigDecimal value,int days){return value==null||days==0?null:value.divide(BigDecimal.valueOf(days),12,RoundingMode.HALF_UP);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java index 69143387c..f1634697a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java @@ -8,6 +8,10 @@ public final class DashboardReadSql { private DashboardReadSql(){} public static String report(Map args){ var p=(AnalyticsSqlRequest)args.get("p"); + if("HOT_DASHBOARD".equals(p.kind()))return AnalyticsInventorySql.typed(AnalyticsInventorySql.dashboardCte(p) + +", hot_statistics AS (SELECT COUNT(CASE WHEN owner_outbound>0 THEN 1 END) hot_eligible,COUNT(CASE WHEN owner_outbound IS NULL THEN 1 END) hot_unknown FROM result)" + +", hot_rank AS (SELECT * FROM result WHERE owner_outbound>0 ORDER BY owner_outbound DESC,order_count DESC,sku_code ASC,customer_code ASC LIMIT 10)" + +", hot_rows AS ("+select("HOT","owner_outbound","hot_rank")+") SELECT s.*,r.* FROM hot_statistics s LEFT JOIN hot_rows r ON 1=1"); // Only dashboard outputs are considered here. Without complete30 all sales-dependent // rankings are empty; observed sales still come from the independent daily query. // Stale stock quantities are already masked to NULL. Keep both profile versions so diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java index 114635903..cc677886d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java @@ -19,7 +19,7 @@ public final class DashboardResponses { public record Bucket(String bucketCode,Long skuCount,BigDecimal knownPhysicalQuantity,Long unknownPhysicalSkuCount){} public record Distribution(String bucketRuleVersion,Long candidateSkuCount,Long excludedZeroPhysicalSkuCount, List buckets,String dataStatus,List reasonCodes,Map unknownReasons){} - public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal outboundQuantity30d, + public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal windowOutboundQuantity, Long skuOutboundOrderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity, BigDecimal defectivePhysicalQuantity,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays){} public record Ranking(LocalDate windowStartDate,LocalDate windowEndDateExclusive,int calculationWindowDays,Long eligibleCount, diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index cae503ab9..f331e206e 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -13,7 +13,7 @@ info: 默认maxQueryDays366,粒度DAY。正式outboundQuantity与observedQuantity独立,全部未知仍为空。 每日COMPLETE仅证明全天覆盖及有效日行或可靠SKU存在;今日/部分日正式量空,观察量可用,未知日期保持空。 SKU区间正式量仅全部日完整才输出;observedQuantity为可靠点合计,完整/部分/未知日数独立。 - 切换图表不改变完整7/30日榜单及30日日均分母;不触发领星请求。 + 热销榜跟随图表所选日期,以已采集有效出库量排序;覆盖不足标记PARTIAL,不改变30日日均分母;不触发领星请求。 运行接口不受经营context限制。动态库存时效、重建和修订待核对独立返回。 阶段进度区分ACCOUNT_SHARED/WAREHOUSE及TASK_CUMULATIVE/ATTEMPT_ONLY,不伪造总百分比。 total未知为空、计数不能跨PAGE/ORDER/INVENTORY_ROW/BUSINESS_KEY/SKU/BATCH单位求和,NONE为非计数阶段且completed/total为空、totalKnown=false;阶段检查点与心跳分开。 @@ -26,7 +26,7 @@ paths: summary: 单仓固定版本经营总览 description: >- 平台独立只读权限platform:self-operated-analytics:list。一次一致快照固定销量和库存版本。 - 默认LAST_15_DAYS仅改变趋势及区间出库,日均、互斥分桶和三个Top10固定至昨日30完整日。 + 默认LAST_15_DAYS同时用于趋势、区间出库和热销Top10;热销按已采集有效量排序并标记覆盖状态。日均、互斥分桶及两个库存Top10固定至昨日30完整日。 不接受货主、SKU、分页、windowDays、topN或其他额外筛选。仓级趋势不套单SKU观察期。 库存严格下钻须携带原版本和drilldownFrom=DASHBOARD;截止时间由服务端根据库存真实观测重算。 parameters: @@ -120,8 +120,8 @@ paths: '403': {description: 非平台身份或缺少独立只读权限} /api/platform/v1/last-leg/self-operated-analytics/hot-skus: get: - summary: 完整7/30日热销Top100 - description: '独立截至queryAsOf昨日的完整窗口,数量大于零,数量降序、SKU单数降序、SKU及货主升序,先Top100再分页;当前缺货仍可入榜。 权限platform:self-operated-analytics:list。' + summary: 所选时段已采集热销Top100 + description: '支持CUSTOM起止日期(含截止日),与总览所选时段一致;未传日期时仍使用windowDays的7/30日窗口。按已采集有效数量排序,覆盖不足由context及coverageStatus提示,不把未知当零。数量大于零,数量降序、SKU单数降序、SKU及货主升序,先Top100再分页;当前缺货仍可入榜。 权限platform:self-operated-analytics:list。' parameters: - {$ref: '#/components/parameters/sourceAccount'} - {$ref: '#/components/parameters/warehouseCode'} @@ -133,6 +133,9 @@ paths: - {$ref: '#/components/parameters/inventoryScopeHash'} - {$ref: '#/components/parameters/queryAsOf'} - {$ref: '#/components/parameters/windowDays'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} - {$ref: '#/components/parameters/current'} - {$ref: '#/components/parameters/size'} responses: @@ -476,7 +479,7 @@ components: productName: {type: string, nullable: true} value: {type: number} unit: {type: string, enum: [PCS, DAY]} - outboundQuantity30d: {type: number, nullable: true} + windowOutboundQuantity: {type: number, nullable: true, description: 榜单实际窗口出库量;热销在部分覆盖时为已采集量} skuOutboundOrderCount: {type: string, nullable: true} physicalQuantity: {type: number, nullable: true} availableQuantity: {type: number, nullable: true} @@ -490,7 +493,7 @@ components: properties: windowStartDate: {type: string, format: date} windowEndDateExclusive: {type: string, format: date} - calculationWindowDays: {type: integer, enum: [30]} + calculationWindowDays: {type: integer, minimum: 1, description: 热销为所选日期天数;库存榜固定30} eligibleCount: {type: string, nullable: true} unassessableCount: {type: string, nullable: true} items: {type: array, maxItems: 10, items: {$ref: '#/components/schemas/DashboardRankingItem'}} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java index 1740cac7a..20aba784c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java @@ -35,6 +35,14 @@ class AnalyticsQueryServiceTest { when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(35)))); when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of()); } + @Test void customHotRangeMatchesSelectedDatesIncludingTodayAndLongHistory(){ + var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(44),D,null,null,1,20,null,null,null,null,null); + var result=service.hotSkus(q); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture()); + assertEquals(D.minusDays(44),cap.getValue().from());assertEquals(D.plusDays(1),cap.getValue().until()); + assertFalse(cap.getValue().completeWindow());assertEquals(D.minusDays(44),result.context().rangeStartDate()); + assertThrows(com.baiyue.common.core.exception.BizException.class,()->new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(2),D,null,7,1,20,null,null,null,null,null).validate(false,false,true)); + } @Test void summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){ when(mapper.count(any())).thenReturn(240L); var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java index af3c646e7..a6d46fcb5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java @@ -34,7 +34,7 @@ class AnalyticsReadMapperTest { } var b=bound(c,"page",Map.of("p",request("HOT"))); var names=b.getParameterMappings().stream().map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList(); - for(String property:List.of("p.account","p.warehouse","p.customer","p.sku","p.salesRun","p.inventoryRun","p.anchor","p.windowFrom","p.size","p.offset"))assertTrue(names.contains(property),property); + for(String property:List.of("p.account","p.warehouse","p.customer","p.sku","p.salesRun","p.inventoryRun","p.anchor","p.from","p.until","p.size","p.offset"))assertTrue(names.contains(property),property); var summaryNames=bound(c,"page",Map.of("p",request("SUMMARY"))).getParameterMappings().stream() .map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList(); for(String property:List.of("p.from","p.until"))assertTrue(summaryNames.contains(property),property); @@ -83,7 +83,8 @@ class AnalyticsReadMapperTest { var p=Map.of("p",request(kind)); String sql=bound(c,"page",p).getSql(); assertTrue(sql.contains("st.snapshot_run_id=?")); assertTrue(sql.contains("st.source_account=?")); assertTrue(sql.contains("st.warehouse_code=?")); assertTrue(sql.contains("AND st.product_quality=0")); - assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date=LEAST(CAST(? AS DATE),TIMESTAMPADD(DAY,-30,CAST(? AS DATE))) AND business_date=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_dateb.skuCount()==null)); assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(30),result.context().fixedWindowStartDate()); - verify(mapper).daily(any(),any(),any());verify(mapper).report(any(),any());verifyNoMoreInteractions(mapper); + verify(mapper).daily(any(),any(),any());verify(mapper,times(2)).report(any(),any());verifyNoMoreInteractions(mapper); var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext")); assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index d8ee3aad3..f152bf4c0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -39,6 +39,21 @@ class DashboardSqlSemanticTest { assertTrue(bound.getSql().endsWith("ORDER BY business_date")); } } + @Test void selectedRangeHotUsesObservedSalesIncludingTodayAndMatchesTop100DespiteIncompleteCoverage()throws Exception{ + fixture("RECENT","50","50","3");fixture("OLD","50","50",null); + daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS"); + var b=request(false,true); + var p=new AnalyticsSqlRequest(b.account(),b.warehouse(),null,null,b.salesRun(),b.inventoryRun(),D.minusDays(2),D.plusDays(1),D,D.minusDays(2), + b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD"); + var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options())); + assertEquals(1,rows.size());assertEquals("RECENT",((Map)rows.get(0)).get("SKU_CODE")); + assertEquals(0,new BigDecimal("10").compareTo((BigDecimal)((Map)rows.get(0)).get("MAIN_VALUE"))); + var top=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),D,p.from(), + p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT"); + var topRows=query(AnalyticsReadMapper.class,"page",Map.of("p",top)); + assertEquals(1,topRows.size());var item=(AnalyticsReadRows.Aggregate)topRows.get(0); + assertEquals("RECENT",item.skuCode());assertEquals(0,new BigDecimal("10").compareTo(item.windowQuantity()));assertNull(item.sales30Quantity()); + } @Test void combinedReportKeepsStatisticsWhenAllThreeRankingsAreEmpty()throws Exception{ var empty=report();assertEquals(1,empty.size());assertEquals(0,count(empty.get(0),"total_skus")); assertNull(empty.get(0).get("rank_code"));assertNull(empty.get(0).get("physical_quantity_known")); From c2bb769676c487c13eb6dee962c13febeeffe83e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 20:02:07 +0800 Subject: [PATCH 54/80] fix(selfop): remove business request count quotas --- .../model/SelfopAnalyticsProperties.java | 2 + .../query/CollectionQueryService.java | 5 ++- .../request/SelfopBusinessBudget.java | 14 +++--- .../request/SelfopEgressService.java | 12 +++--- .../analytics/sync/SelfopTaskService.java | 2 +- .../openapi/self-operated-analytics-v1.yaml | 2 +- .../query/CollectionQueryServiceTest.java | 25 ++++++++++- .../SelfopBusinessBudgetPersistenceTest.java | 2 +- .../request/SelfopBusinessBudgetTest.java | 17 ++++---- .../request/SelfopEgressServiceTest.java | 43 +++++++++++-------- .../analytics/sync/SelfopTaskServiceTest.java | 2 +- 11 files changed, 80 insertions(+), 46 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java index 0bca860e1..35d33217e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java @@ -24,6 +24,7 @@ public class SelfopAnalyticsProperties { private LocalDate transferredBudgetDate; private LocalDateTime transferredCooldownUntilUtc; private String transferredPauseReason; + /** VALIDATION-only daily ceiling; BUSINESS retains counts without a daily quota. */ private int dailyRequestBudget = 0; private int businessMinRequestIntervalMs = 5000; private int maxRateLimitFailures = 5; @@ -43,6 +44,7 @@ public class SelfopAnalyticsProperties { private String sourceAccount = ""; private String sourceQueryTimezone; private String quantityUnit; + /** Per-dispatch slice size, never a BUSINESS root/session HTTP-count ceiling. */ private int requestBudget = 100; private int timeBudgetSeconds = 120; private int minRequestIntervalMs = 1100; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java index f07b1816c..7c59b955f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java @@ -295,7 +295,7 @@ public class CollectionQueryService { JsonNode budget=boundedObject(task.businessRootBudgetJson()); Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp"); Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc"); - if(budget==null||maximum==null||maximum<=0||maximum>Integer.MAX_VALUE||reserved==null||reserved>maximum + if(budget==null||maximum==null||maximum<0||maximum>Integer.MAX_VALUE||reserved==null||reserved>Integer.MAX_VALUE ||confirmed==null||confirmed>reserved||started==null||deadline==null||!deadline.isAfter(started)) return "BUSINESS_SESSION_BUDGET_MISSING"; JsonNode stopped=budget.get("stopReason"); @@ -332,7 +332,7 @@ public class CollectionQueryService { var p=mapper.protection(account); JsonNode budget=p==null?null:boundedObject(p.apiBudgetJson()); return new CollectionResponses.Protection(p==null?null:utc(p.apiCooldownUntilUtc()),p==null?null:p.apiPauseReason(), - config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"), + "BUSINESS".equals(config.getCollectionMode())?0:config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"), budget==null||!budget.path("budgetDate").isTextual()?null:budget.path("budgetDate").textValue()); } private String executionBlock(String warehouse,CollectionResponses.Protection protection) { @@ -347,6 +347,7 @@ public class CollectionQueryService { if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() ||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID"; + if("BUSINESS".equals(config.getCollectionMode()))return null; if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED"; if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate()) &&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java index c6d6eb667..eea884254 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java @@ -3,24 +3,25 @@ package com.baiyue.lastleg.selfoperated.analytics.request; import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; import java.time.LocalDateTime; -/** TEST manual BUSINESS session ceiling, frozen on its root and shared by descendants. */ +/** BUSINESS session accounting and time ceiling, shared by the root and descendants. */ public final class SelfopBusinessBudget { - public int maxHttp, http, confirmedHttp; + /** Recorded field only; BUSINESS has no HTTP-count ceiling. New sessions write zero. */ + public int maxHttp; + public int http, confirmedHttp; public LocalDateTime startedAtUtc, deadlineAtUtc; public String stopReason; - public static String freeze(LocalDateTime now, int maxHttp, int seconds) { - if (now == null || maxHttp < 1 || seconds < 1) + public static String freeze(LocalDateTime now, int seconds) { + if (now == null || seconds < 1) throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID"); var budget = new SelfopBusinessBudget(); - budget.maxHttp = maxHttp; budget.startedAtUtc = now; budget.deadlineAtUtc = now.plusSeconds(seconds); return SelfopSupport.json(budget); } public void validate() { - if (maxHttp < 1 || http < 0 || http > maxHttp || confirmedHttp < 0 || confirmedHttp > http + if (http < 0 || confirmedHttp < 0 || confirmedHttp > http || startedAtUtc == null || deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc) || stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}")) throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null); @@ -30,7 +31,6 @@ public final class SelfopBusinessBudget { validate(); if (stopReason != null) return stopReason; if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; - if (http >= maxHttp) return "BUSINESS_SESSION_HTTP_LIMIT"; return null; } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index e61297340..5e27e99c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -39,7 +39,7 @@ public class SelfopEgressService { } int interval="VALIDATION".equals(context.executionPurpose())?Math.max(5000,budget.minIntervalMs):budget.minIntervalMs; if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000)); - if(budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); + if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); @@ -58,8 +58,7 @@ public class SelfopEgressService { var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); if(testBusiness(c.executionPurpose())){ var b=business(root);verifyBusinessTask(task,root); - // The last reservation already spent its HTTP allowance. Recheck time/stop, - // not http>=maxHttp, or that paid reservation could never be authorized. + // Authorization preserves the root time/stop fence independently of request accounting. String limit=b.stopReason!=null?b.stopReason:!now.isBefore(b.deadlineAtUtc)?"BUSINESS_SESSION_DEADLINE":null; if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);} if(pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc; @@ -128,7 +127,7 @@ public class SelfopEgressService { if("BUSINESS".equals(purpose)&&!("production".equals(config.getEnvironment())||"test".equals(config.getEnvironment())))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH"); if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); - if(config.getDailyRequestBudget()<1||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); + if(("VALIDATION".equals(purpose)&&config.getDailyRequestBudget()<1)||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); } private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;} private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){ @@ -136,8 +135,9 @@ public class SelfopEgressService { if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID"); LocalDate day=now.plusHours(8).toLocalDate(); if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;} - if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget(); - b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs()); + if("BUSINESS".equals(config.getCollectionMode()))b.dailyLimit=0; + else {if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget();b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());} + b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs()); b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds())); if(!Objects.equals(config.getExclusiveGrantId(),b.importedGrantId)){ if(config.getTransferredBudgetDate()==null||!config.getTransferredBudgetDate().equals(day)||config.getTransferredDailyHttpCount()<0)throw reject("HANDOFF_BUDGET_DATE_UNCONFIRMED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java index 3e15db987..9206dfb77 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -113,7 +113,7 @@ public class SelfopTaskService { if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); task.setBusinessRootTaskId(task.getId()); - task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getRequestBudget(),config.getTimeBudgetSeconds())); + task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds())); }else { var parent=businessParent(task); require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index f331e206e..a585486bd 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -693,7 +693,7 @@ components: properties: accountCooldownUntil: {type: string, format: date-time, nullable: true} accountPauseReason: {type: string, nullable: true} - dailyRequestBudget: {type: integer, nullable: true} + dailyRequestBudget: {type: integer, minimum: 0, nullable: true, description: "BUSINESS 为 0,表示不限制累计请求次数;VALIDATION 为每日请求上限。实际请求计数继续累计。"} reservedHttpRequests: {type: string, nullable: true} confirmedHttpRequests: {type: string, nullable: true} budgetDate: {type: string, format: date, nullable: true} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java index 853711e3e..9480f74a8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java @@ -97,6 +97,29 @@ class CollectionQueryServiceTest { result=service.taskDetail(t.taskNo(),q(null,null,null)).task(); assertEquals("UNAVAILABLE",result.stageEstimate().status());assertNull(result.stageEstimate().remainingSeconds()); } + @Test void businessOverviewAndRetryIgnoreCumulativeRequestCapsButKeepCounters() { + enableBusiness("test"); + when(mapper.protection("primary")).thenReturn(new Protection(null,null, + "{\"budgetDate\":\"2026-10-12\",\"dailyLimit\":100,\"reservedHttp\":12001,\"confirmedHttp\":12000}")); + for(int configured:List.of(100,0)) { + config.setDailyRequestBudget(configured); + for(var row:service.overview(q(null,null,null)).warehouses()) { + assertTrue(row.executionEnabled());assertNull(row.disabledReason()); + assertEquals(0,row.protection().dailyRequestBudget()); + assertEquals(12001L,row.protection().reservedHttpRequests()); + assertEquals(12000L,row.protection().confirmedHttpRequests()); + } + } + for(int maximum:List.of(5,0)) { + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask( + businessBudget(now.plusHours(1).toString(),null,6000).replace("\"maxHttp\":5","\"maxHttp\":"+maximum))); + assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions()); + } + config.setCollectionMode("VALIDATION");config.setDailyRequestBudget(100); + for(var row:service.overview(q(null,null,null)).warehouses()) { + assertFalse(row.executionEnabled());assertEquals("DAILY_BUDGET_EXHAUSTED",row.disabledReason()); + } + } @Test void controlledTestBusinessProjectsEnabledInventoryPublicationWithoutAutomaticSchedule() { config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setQuantityUnit("PCS"); config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true); @@ -150,7 +173,7 @@ class CollectionQueryServiceTest { valid.replace(now.minusHours(1).toString(),now.plusHours(1).toString()), valid.replace(now.minusHours(1).toString(),now.plusHours(2).toString()), valid.replace("\"maxHttp\":5","\"maxHttp\":2147483648"), - valid.replace("\"http\":5","\"http\":6"), + valid.replace("\"http\":5","\"http\":2147483648"), valid.replace("\"confirmedHttp\":5","\"confirmedHttp\":6"))) { when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(budget,true)); var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java index 5460dda91..c5a2b13e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java @@ -28,7 +28,7 @@ class SelfopBusinessBudgetPersistenceTest { } var tasks=session.getMapper(SelfopSyncTaskMapper.class); var stale=new SelfopSyncTask();stale.setId(1L);stale.setStatus("RUNNING");stale.setBusinessRootTaskId(1L); - stale.setBusinessBudgetJson(SelfopBusinessBudget.freeze(LocalDateTime.of(2026,10,13,0,0),2,120)); + stale.setBusinessBudgetJson(SelfopBusinessBudget.freeze(LocalDateTime.of(2026,10,13,0,0),120)); assertEquals(1,tasks.insert(stale)); // NEVER updates must still allow initial freezing. var charged=SelfopSupport.read(stale.getBusinessBudgetJson(),SelfopBusinessBudget.class); charged.http=2;charged.confirmedHttp=1; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java index 4758b48ac..5ebd87176 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java @@ -9,25 +9,26 @@ class SelfopBusinessBudgetTest { private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0); @Test void serializedConsumptionAndDeadlineSurviveReload() { - var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class); + var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,60),SelfopBusinessBudget.class); budget.http=2;budget.confirmedHttp=1; var restored=SelfopSupport.read(SelfopSupport.json(budget),SelfopBusinessBudget.class); assertEquals(start.plusSeconds(60),restored.deadlineAtUtc); assertEquals(2,restored.http);assertEquals(1,restored.confirmedHttp); - assertEquals("BUSINESS_SESSION_HTTP_LIMIT",restored.limit(start.plusSeconds(1))); + assertEquals(0,restored.maxHttp);assertNull(restored.limit(start.plusSeconds(1))); assertEquals("BUSINESS_SESSION_DEADLINE",restored.limit(start.plusSeconds(60))); restored.stopReason="BUSINESS_SESSION_HTTP_LIMIT"; assertEquals(restored.stopReason,restored.limit(start.plusDays(1))); } - @Test void noImplicitUnlimitedOrInvalidAccounting() { - assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,0,60)); - assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,1,0)); + @Test void unlimitedHttpRetainsStrictDeadlineAndAccounting() { + assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(null,60)); + assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,0)); assertThrows(EgressRejectedException.class,()->new SelfopBusinessBudget().validate()); - var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class); + var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,60),SelfopBusinessBudget.class); budget.confirmedHttp=1; assertThrows(EgressRejectedException.class,budget::validate); - budget.confirmedHttp=0;budget.http=3; - assertThrows(EgressRejectedException.class,budget::validate); + budget.confirmedHttp=12001;budget.http=12001;budget.maxHttp=5000; + assertDoesNotThrow(budget::validate);assertNull(budget.limit(start.plusSeconds(1))); + budget.http=-1;assertThrows(EgressRejectedException.class,budget::validate); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index e79ee25fa..912feb18e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -148,24 +148,31 @@ class SelfopEgressServiceTest { } } private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");} - private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),http,seconds));} + private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),seconds));} private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);} - @Test void testBusinessLastReservedAttemptIsAllowedAndRestartCannotRenewSession(){ - testBusiness(1,300);var start=businessBudget().startedAtUtc;var deadline=businessBudget().deadlineAtUtc; - var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(businessBudget().http).isEqualTo(1); - service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + @Test void businessSurpassesPriorDailyAndRootHttpCapsAcrossRestartWithoutRenewingDeadline(){ + testBusiness(5000,300);cfg.setDailyRequestBudget(12000);cfg.setTransferredDailyHttpCount(12000); + var rootBudget=businessBudget();rootBudget.maxHttp=5000;rootBudget.http=5000;rootBudget.confirmedHttp=5000;task.setBusinessBudgetJson(SelfopSupport.json(rootBudget)); + var start=rootBudget.startedAtUtc;var deadline=rootBudget.deadlineAtUtc; + var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null); service.complete(completion);new SelfopEgressService(mapper,cfg,clock).complete(completion); - assertThat(businessBudget().confirmedHttp).isEqualTo(1);clock.advance(10); - cfg.setRequestBudget(1000);cfg.setTimeBudgetSeconds(3600);cfg.setExclusiveGrantId("renewed"); - var renewed=new SelfopRequestContext("synthetic","BUSINESS",1L,"selfop-1","renewed",1L,2L); - assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(renewed,Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)) - .hasMessage("BUSINESS_SESSION_HTTP_LIMIT").satisfies(e->{assertThat(((EgressRejectedException)e).sampleDone()).isFalse();assertThat(((EgressRejectedException)e).waitUntilUtc()).isNull();}); - assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(task.getNextRetryAtUtc()).isNull(); - assertThat(businessBudget().startedAtUtc).isEqualTo(start);assertThat(businessBudget().deadlineAtUtc).isEqualTo(deadline); - assertThat(businessBudget().maxHttp).isEqualTo(1);assertThat(budget().reservedHttp).isEqualTo(1); - verify(mapper).terminateBusinessTasks(1L,"synthetic");verify(mapper).terminateBusinessRuns(eq(1L),eq("synthetic"),eq("BUSINESS_SESSION_HTTP_LIMIT"),any()); - verify(mapper,never()).terminateValidationRuns(any(),any(),any(),any()); + assertThat(businessBudget().http).isEqualTo(5001);assertThat(businessBudget().confirmedHttp).isEqualTo(5001); + assertThat(budget().dailyLimit).isZero();assertThat(budget().reservedHttp).isEqualTo(12001);assertThat(budget().confirmedHttp).isEqualTo(1); + clock.advance(10);cfg.setRequestBudget(1000);cfg.setTimeBudgetSeconds(3600); + var next=new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL);success(next,Operation.OUTBOUND_QUERY); + assertThat(businessBudget().http).isEqualTo(5002);assertThat(businessBudget().startedAtUtc).isEqualTo(start);assertThat(businessBudget().deadlineAtUtc).isEqualTo(deadline); + verify(mapper,never()).terminateBusinessTasks(any(),any());verify(mapper,never()).terminateValidationRuns(any(),any(),any(),any()); + } + @Test void businessWithoutDailyQuotaStillUsesIntervalAndActual429Review(){ + testBusiness(1,300);cfg.setDailyRequestBudget(0);cfg.setBusinessMinRequestIntervalMs(1200); + var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT"); + clock.advance(2);var next=reserve(Operation.OUTBOUND_QUERY,1,null,null);service.authorize(new AuthorizationRequest(next,Operation.OUTBOUND_QUERY,PROVIDER)); + service.complete(new CompletionRequest(next,Operation.OUTBOUND_QUERY,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null)); + assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);assertThat(budget().rateLimitFailures).isEqualTo(1); + assertThat(account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN"); } @Test void testBusinessDeadlineClipsPermitAndAuthorizeRechecksBeforeHttp(){ testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc); @@ -187,7 +194,7 @@ class SelfopEgressServiceTest { testBusiness(5,300);task.setBusinessBudgetJson(null); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().reservedHttp).isZero(); - task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),5,300));task.setBusinessRootTaskId(999L); + task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),300));task.setBusinessRootTaskId(999L); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); assertThat(businessBudget().http).isZero(); } @@ -201,8 +208,8 @@ class SelfopEgressServiceTest { var c=new SelfopEgressService(mapper,cfg,clock).reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL);success(c,Operation.PRODUCT_INVENTORY); assertThat(businessBudget().http).isEqualTo(2);assertThat(businessBudget().confirmedHttp).isEqualTo(2);assertThat(child.getBusinessBudgetJson()).isNull(); child.setBusinessRootTaskId(999L);assertThatThrownBy(()->service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); - child.setBusinessRootTaskId(1L);assertThatThrownBy(()->service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL)).hasMessage("BUSINESS_SESSION_HTTP_LIMIT"); - verify(mapper).terminateBusinessTasks(1L,"synthetic"); + child.setBusinessRootTaskId(1L);success(service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL),Operation.PRODUCT_INVENTORY); + assertThat(businessBudget().http).isEqualTo(3);verify(mapper,never()).terminateBusinessTasks(any(),any()); } @Test void productionBusinessDoesNotRequireTestBudget(){ business();success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java index aae001e24..c9895aede 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java @@ -371,7 +371,7 @@ class SelfopTaskServiceTest { var root=task("READY");root.setRequestKey("manual:frozen");root.setRequestedBy("operator"); service.create(root); var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); - assertThat(root.getBusinessRootTaskId()).isEqualTo(root.getId());assertThat(budget.maxHttp).isEqualTo(7); + assertThat(root.getBusinessRootTaskId()).isEqualTo(root.getId());assertThat(budget.maxHttp).isZero(); assertThat(budget.deadlineAtUtc).isEqualTo(budget.startedAtUtc.plusSeconds(91)); var child=task("READY");child.setId(42L);child.setBusinessRootTaskId(root.getId()); assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(root.getId()); From b033a89aaf0af286cdf60cb2cdc2d51fd02deab1 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 13 Oct 2026 20:51:44 +0800 Subject: [PATCH 55/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- .../transfer/dto/WarehouseTransferResponses.java | 3 ++- .../mapper/WarehouseTransferPalletMapper.java | 9 +++++++++ .../transfer/service/WarehouseTransferService.java | 13 +++++++++++-- .../service/WarehouseTransferServiceTest.java | 12 ++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index 7a3ece053..e8dfb55fb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -36,7 +36,8 @@ public final class WarehouseTransferResponses { String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, String relabelStatus, String relabelFulfillmentStatus, - String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} + String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt, + LocalDateTime reviewedAt, LocalDateTime measuredAt, LocalDateTime outboundAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, String status, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java index fb9361e0e..4f0454b43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java @@ -10,6 +10,15 @@ import java.util.List; @Mapper public interface WarehouseTransferPalletMapper extends BaseMapper { + record MeasurementTime(Long transferOrderId, java.time.LocalDateTime measuredAt) {} + + @Select("") + List measurementTimes(@Param("orderIds") List orderIds); + @Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no") List listOrder(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); 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 075c443e8..662c8482b 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 @@ -428,6 +428,7 @@ public class WarehouseTransferService { .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); Map relabelApplicationsByNo = relabelApplications(result.getRecords()); + Map measurementTimes = measurementTimes(result.getRecords()); List records = result.getRecords().stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), @@ -439,7 +440,8 @@ public class WarehouseTransferService { value.getRelabelNo() == null || relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(), value.getStatus(), - value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); + value.getEstimatedArrivalAt(), value.getCreatedAt(), value.getReviewedAt(), + measurementTimes.get(value.getId()), value.getOutboundAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } @@ -1320,10 +1322,17 @@ public class WarehouseTransferService { relabel == null ? null : relabel.getStatus(), relabel == null ? null : relabel.getFulfillmentStatus(), value.getStatus(), - value.getEstimatedArrivalAt(), value.getCreatedAt()); + value.getEstimatedArrivalAt(), value.getCreatedAt(), value.getReviewedAt(), null, value.getOutboundAt()); }).toList(); } + private Map measurementTimes(List transferOrders) { + if (transferOrders.isEmpty()) return Map.of(); + return transferPallets.measurementTimes(transferOrders.stream().map(WarehouseTransferOrder::getId).toList()) + .stream().collect(Collectors.toMap(WarehouseTransferPalletMapper.MeasurementTime::transferOrderId, + WarehouseTransferPalletMapper.MeasurementTime::measuredAt)); + } + private Map relabelApplications(List transferOrders) { Set relabelNos = transferOrders.stream().map(WarehouseTransferOrder::getRelabelNo) .filter(StringUtils::hasText).collect(Collectors.toSet()); 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 d3c3dc809..1462a23af 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 @@ -126,6 +126,12 @@ class WarehouseTransferServiceTest { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_RELABEL); transfer.setNeedRelabel(true); transfer.setRelabelNo("RL-20260923-000001"); + var reviewedAt = LocalDateTime.of(2026, 10, 9, 15, 36, 14); + var measuredAt = reviewedAt.plusNanos(300_000_000); + var outboundAt = reviewedAt.plusSeconds(2); + transfer.setReviewedAt(reviewedAt); transfer.setOutboundAt(outboundAt); + when(transferPallets.measurementTimes(List.of(transfer.getId()))).thenReturn(List.of( + new WarehouseTransferPalletMapper.MeasurementTime(transfer.getId(), measuredAt))); Page resultPage = new Page<>(1, 20, 1); resultPage.setRecords(List.of(transfer)); RelabelApplication relabel = new RelabelApplication(); @@ -141,6 +147,9 @@ class WarehouseTransferServiceTest { assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); assertEquals("COMPLETED", result.records().get(0).relabelStatus()); assertEquals("READY_FOR_DISPATCH", result.records().get(0).relabelFulfillmentStatus()); + assertEquals(reviewedAt, result.records().get(0).reviewedAt()); + assertEquals(measuredAt, result.records().get(0).measuredAt()); + assertEquals(outboundAt, result.records().get(0).outboundAt()); } @Test @@ -157,6 +166,9 @@ class WarehouseTransferServiceTest { assertEquals(1, result.records().size()); assertNull(result.records().get(0).relabelStatus()); assertNull(result.records().get(0).relabelFulfillmentStatus()); + assertNull(result.records().get(0).reviewedAt()); + assertNull(result.records().get(0).measuredAt()); + assertNull(result.records().get(0).outboundAt()); verify(relabelApplications, never()).selectList(any()); } From 242a76221ea9b0afa84330cb07db30401b57cc61 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 21:13:31 +0800 Subject: [PATCH 56/80] feat(self-operated): persist continuous collection and frozen coverage --- .../config/LingxingEgressProperties.java | 3 +- .../provider/lingxing/LingxingEgressGate.java | 10 +- .../src/main/resources/application.yml | 3 +- .../config/LingxingEgressPropertiesTest.java | 15 +- .../lingxing/LingxingEgressGateTest.java | 88 +++++++++++- .../LingxingHttpBudgetAuthorityTest.java | 4 +- .../lingxing/LingxingWmsClientTest.java | 2 +- .../mapper/SelfopCoordinationMapper.java | 2 + .../model/SelfopAnalyticsProperties.java | 2 + .../model/SelfopCollectionPolicy.java | 34 ++++- .../analytics/model/SelfopSalesCoverage.java | 46 ++++++ .../analytics/model/SelfopStageEstimate.java | 11 +- .../outbound/OutboundAggregateMapper.java | 16 +++ .../OutboundCandidateTransaction.java | 10 +- .../query/AnalyticsQueryService.java | 28 ++-- .../analytics/query/AnalyticsReadSql.java | 30 ++++ .../analytics/query/AnalyticsSqlRequest.java | 3 +- .../query/CollectionQueryService.java | 27 +++- .../analytics/query/CollectionResponses.java | 2 +- .../query/DashboardQueryService.java | 8 +- .../analytics/query/DashboardSalesPolicy.java | 13 +- .../analytics/query/SalesQueryCoverage.java | 33 +++++ .../request/SelfopBusinessBudget.java | 11 +- .../request/SelfopEgressService.java | 43 +++--- .../sync/SelfopExecutionService.java | 1 + .../analytics/sync/SelfopSalesPublisher.java | 3 +- .../analytics/sync/SelfopSchedulePlanner.java | 83 +++++++++++ .../analytics/sync/SelfopTaskService.java | 47 ++++--- .../src/main/resources/application.yml | 1 + .../openapi/self-operated-analytics-v1.yaml | 4 +- .../model/SelfopStageEstimateTest.java | 22 ++- ...oundCandidateBuildMySqlAcceptanceTest.java | 2 +- .../OutboundCandidateBuilderTest.java | 43 ++++++ .../query/AnalyticsQueryServiceTest.java | 65 +++++++++ .../query/AnalyticsReadMapperTest.java | 2 +- .../query/AnalyticsSqlSemanticTest.java | 32 +++-- .../query/CollectionQueryServiceTest.java | 40 ++++++ .../analytics/query/DashboardPolicyTest.java | 25 ++-- .../query/DashboardQueryServiceTest.java | 4 +- .../query/DashboardSqlSemanticTest.java | 16 +-- .../request/SelfopBusinessBudgetTest.java | 9 ++ .../request/SelfopEgressServiceTest.java | 133 ++++++++++++++++++ .../sync/SelfopSalesPublisherTest.java | 30 ++++ .../sync/SelfopSchedulePlannerTest.java | 89 ++++++++++++ .../analytics/sync/SelfopTaskServiceTest.java | 44 ++++++ 45 files changed, 1021 insertions(+), 118 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java index b59ace7b4..dbcdeeb46 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java @@ -12,7 +12,8 @@ import java.time.Instant; public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode, @DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId, Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval, - @DefaultValue("5m") Duration cooldownFallback) { + @DefaultValue("5m") Duration cooldownFallback, + @DefaultValue("false") boolean continuousBusinessCollectionEnabled) { public LingxingEgressProperties { collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode; environmentLabel = environmentLabel == null ? "test" : environmentLabel; diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java index e01f5be97..aafb52dd6 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java @@ -62,7 +62,7 @@ public final class LingxingEgressGate { || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() || !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) || permit.expiresAt().isAfter(clock.instant().plusSeconds(30)) - || permit.expiresAt().isAfter(properties.externalExclusiveUntil())) { + || (!continuousBusiness() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) { recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID"); } // A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state. @@ -106,10 +106,14 @@ public final class LingxingEgressGate { if (context == null || context.executionPurpose() != properties.collectionMode() || !context.wellFormed() || !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID"); - if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId()) - || properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()) + if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank() + || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId()) + || (!continuousBusiness() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()))) || properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED"); } + private boolean continuousBusiness() { + return properties.collectionMode() == CollectionMode.BUSINESS && properties.continuousBusinessCollectionEnabled(); + } private static boolean identifier(String value, int maximum) { return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); } diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 4264c372f..51fa8a5d3 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -44,13 +44,14 @@ baiyue: request-timeout: 15s egress: collection-mode: ${LINGXING_COLLECTION_MODE:OFF} + continuous-business-collection-enabled: ${LINGXING_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false} environment-label: ${LINGXING_ENVIRONMENT_LABEL:test} source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary} external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:} budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303} min-start-interval: 5s cooldown-fallback: 5m - # Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri. + # Finite sessions require external-exclusive-until; continuous BUSINESS still requires the exclusive grant ID and authority. # Missing proof/authority fails closed even when the old enabled switch is true. baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} diff --git a/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java index 1da7522cc..ad3667d9b 100644 --- a/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java @@ -7,12 +7,23 @@ import java.util.List; import static org.assertj.core.api.Assertions.*; class LingxingEgressPropertiesTest { + @Test void continuousBusinessFlagBindsOnlyWhenExplicitlyEnabled() { + String prefix = "baiyue.integration.lingxing-wms.egress"; + var input = new java.util.HashMap(); + input.put(prefix + ".collection-mode", "BUSINESS"); + var source = new org.springframework.boot.context.properties.source.MapConfigurationPropertySource(input); + var binder = new org.springframework.boot.context.properties.bind.Binder(source); + var target = org.springframework.boot.context.properties.bind.Bindable.of(LingxingEgressProperties.class); + assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isFalse(); + source.put(prefix + ".continuous-business-collection-enabled", "true"); + assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isTrue(); + } private LingxingEgressProperties properties(CollectionMode mode, Duration interval, Duration cooldown) { - return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown); + return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown, false); } @Test void businessAcceptsPositiveMillisecondIntervalsIncludingProbeSteps() { - for (long millis : new long[]{1, 1500, 2000, 3000, 5000, 300000}) { + for (long millis : new long[]{1, 1200, 1500, 2000, 3000, 5000, 300000}) { assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval()) .isEqualTo(Duration.ofMillis(millis)); } diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java index c7a2da739..92ae145ba 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java @@ -54,7 +54,88 @@ class LingxingEgressGateTest { private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); } static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); } static LingxingEgressProperties props(CollectionMode mode, String environment) { - return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null); + return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null, false); + } + private LingxingEgressGate continuousGate(CollectionMode mode, String grant, Instant until) { + return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", "demo-account", grant, until, + URI.create("http://127.0.0.1"), null, null, true), ledger, clock, clock::advanceMillis); + } + @Test void continuousBusinessCrossesOldGrantExpiryButKeepsFiniteSingleUsePermits() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", NOW.minusSeconds(1)); + assertThat(gate.execute(businessContext("continuous"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + clock.advanceMillis(86400000); + var restarted = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThat(restarted.execute(businessContext("next-day"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, + clock.instant().plusSeconds(31), true, true); + assertThatThrownBy(() -> restarted.execute(businessContext("too-long"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("BUDGET_PERMIT_INVALID"); + assertThat(ledger.completions).hasSize(2); + } + @Test void continuousFlagNeverReplacesMatchingGrantOrFiniteValidationWindow() { + for (String grant : Arrays.asList(null, "", "other-grant")) { + var gate = continuousGate(CollectionMode.BUSINESS, grant, null); + assertThatThrownBy(() -> gate.execute(businessContext("missing-grant"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + for (Instant until : Arrays.asList(null, NOW)) { + var gate = continuousGate(CollectionMode.VALIDATION, "demo-grant", until); + assertThatThrownBy(() -> gate.execute(context("validation"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + assertThat(ledger.authorizations).isZero(); + } + @Test void continuousBusinessStillPausesOnUnconfirmedCompletion() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + ledger.failComplete = true; + assertThatThrownBy(() -> gate.execute(businessContext("lost"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_COMPLETION_UNCONFIRMED"); + ledger.failComplete = false; clock.advanceMillis(86400000); + assertThatThrownBy(() -> gate.execute(businessContext("after-lost"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1); + } + @Test void disabledContinuousBusinessStillRequiresUnexpiredGrant() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", NOW, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false), + ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> gate.execute(businessContext("disabled"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + assertThat(ledger.authorizations).isZero(); + } + @Test void continuousBusinessRetains1200MillisSpacingAndOneHttpPerPermit() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, true), + ledger, clock, clock::advanceMillis); + gate.execute(businessContext("first-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }); + gate.execute(businessContext("second-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }); + assertThat(Duration.between(NOW, clock.instant()).toMillis()).isGreaterThanOrEqualTo(1200); + assertThatThrownBy(() -> gate.execute(businessContext("one-send"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).hasSize(3).allSatisfy(c -> assertThat(c.actualHttpAttempts()).isEqualTo(1)); + } + @Test void continuousBusinessCannotSendWithExpiredPermitAfterSigning() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThatThrownBy(() -> gate.execute(businessContext("expired-permit"), Operation.OUTBOUND_DETAIL, HASH, + h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero()); + } + @Test void continuousBusinessRateCooldownStillRequiresFreshAuthorityApproval() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("continuous-rate"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.rateLimited(null); throw rateLimit(); })); + assertThatThrownBy(() -> gate.execute(businessContext("during-cooldown"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("ACCOUNT_PAUSED"); + clock.advanceMillis(300000);ledger.denial = new AuthorizationDenial("ACCOUNT_REQUIRES_REVIEW", false, null); + assertThatThrownBy(() -> gate.execute(businessContext("authority-denied"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("BUDGET_AUTHORIZATION_DENIED"); + assertThat(ledger.completions).hasSize(1); + ledger.denial = null; + assertThat(gate.execute(businessContext("fresh-approved"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).accountPaused()).isFalse(); + assertThat(ledger.completions).hasSize(2); } @Test void failureCompletionPreservesNullableTransportAndSourceCodes() { for (Integer status : new Integer[] {null, 503}) { @@ -72,8 +153,9 @@ class LingxingEgressGateTest { } } @Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() { - var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null); + var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null, false); assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF); + assertThat(defaults.continuousBusinessCollectionEnabled()).isFalse(); assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5)); var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis); assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF"); @@ -193,7 +275,7 @@ class LingxingEgressGateTest { } finally { release.countDown(); executor.shutdownNow(); } } @Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() { - assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null, false)).isInstanceOf(IllegalArgumentException.class); assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1"); } @Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() { diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java index 729101ff9..1df6dc50d 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java @@ -43,7 +43,7 @@ class LingxingHttpBudgetAuthorityTest { @AfterEach void stop() { server.stop(0); } private LingxingHttpBudgetAuthority authority(String serviceToken) { return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", - LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null), mapper, serviceToken); + LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false), mapper, serviceToken); } private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); } private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, @@ -86,7 +86,7 @@ class LingxingHttpBudgetAuthorityTest { } @Test void missingServerConfiguredAuthorityPreventsHttp() { var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", - LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null), mapper, "synthetic-token"); + LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null, false), mapper, "synthetic-token"); assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero(); } @Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception { diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index cc0a27698..e2d42ba2c 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -254,7 +254,7 @@ class LingxingWmsClientTest { var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), "synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2)); var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null, - NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null); + NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null, false); var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) { @Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); } }; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java index f1ded1371..2415972b9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java @@ -17,6 +17,8 @@ public interface SelfopCoordinationMapper { SelfopSyncRun lockRun(long id); @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") List due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); + @Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR))) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") + List dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account); @Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") long barriers(@Param("account") String account, @Param("warehouse") String warehouse); @Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND apply_state='APPLIED' AND (query_from_utc IS NULL OR (query_from_utc>=#{from} AND query_until_utc<=#{until})) AND status IN ('READY','RUNNING','RETRY_WAIT','DEAD')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java index 35d33217e..adebbd356 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java @@ -13,6 +13,8 @@ import java.time.LocalDateTime; @ConfigurationProperties(prefix = "baiyue.self-operated-analytics") public class SelfopAnalyticsProperties { private String collectionMode = "OFF"; + /** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */ + private boolean continuousBusinessCollectionEnabled = false; private String environment = "test"; private String exclusiveGrantId; private LocalDateTime exclusiveGrantExpiresAtUtc; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java index 435f6e8ea..1998b865c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java @@ -28,14 +28,46 @@ public final class SelfopCollectionPolicy { requireExecution(config,"BUSINESS"); require("production".equals(config.getEnvironment()),"SELFOP_AUTOMATIC_PLANNING_ENVIRONMENT_REJECTED"); } + public static final String CONTINUOUS_PLANNER="SELFOP_CONTINUOUS_PLANNER"; + public static boolean continuousBusiness(SelfopAnalyticsProperties config){ + return config.isContinuousBusinessCollectionEnabled()&&"BUSINESS".equals(config.getCollectionMode())&&businessEnvironment(config); + } + /** Strict persisted server identity; enabling continuous mode never admits old cron slots. */ + public static boolean continuousBusinessRoot(SelfopSyncTask task){ + if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null + ||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false; + String key=task.getRequestKey();if(key==null)return false; + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&"BACKFILL".equals(task.getTaskType())&&task.getDiscoveryScopeId()!=null + &&task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&&task.getQueryUntilUtc().equals(task.getQueryFromUtc().plusDays(1))){ + var local=task.getQueryFromUtc().plusHours(8); + if(!local.toLocalTime().equals(java.time.LocalTime.MIDNIGHT)||!key.equals("continuous:outbound:"+local.toLocalDate()+":"+task.getScopeHash().substring(0,12)))return false; + try{Map q=read(task.getQueryScopeJson(),Map.class); + return q!=null&&q.get("parentTaskId")==null&&q.get("deliveryNo")==null&&q.get("customerCode")==null + &&local.toString().equals(q.get("startTime"))&&task.getQueryUntilUtc().plusHours(8).toString().equals(q.get("endTime")); + }catch(RuntimeException invalid){return false;} + } + if(!"WAREHOUSE_SYNC".equals(task.getTaskRole())||!"SCHEDULED".equals(task.getTaskType())||task.getScopeId()==null + ||task.getDiscoveryScopeId()!=null||task.getWarehouseCode()==null||task.getPlannedAtUtc()==null + ||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null + ||!key.equals("continuous:inventory:"+task.getWarehouseCode()+":"+task.getPlannedAtUtc()+":"+task.getScopeHash().substring(0,12)))return false; + try{Map q=read(task.getQueryScopeJson(),Map.class); + return q!=null&&"INVENTORY".equals(q.get("dataFamily"))&&"Asia/Shanghai".equals(q.get("businessTimezone")) + &&java.util.Objects.equals(task.getSourceAccount(),q.get("sourceAccount"))&&java.util.Objects.equals(task.getWarehouseCode(),q.get("warehouseCode")); + }catch(RuntimeException invalid){return false;} + } /** TEST may poll only persisted manual roots and their existing routing/refresh descendants. */ public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function lookup) { + return controlledBusinessTask(task,lookup,false); + } + public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function lookup,boolean allowContinuous) { if(task==null)return false; String account=task.getSourceAccount();var seen=new java.util.HashSet(); for(int depth=0;task!=null&&depth<64;depth++) { if(!"BUSINESS".equals(task.getExecutionPurpose())||task.getValidationRootTaskId()!=null - ||!java.util.Objects.equals(account,task.getSourceAccount())||"SCHEDULED".equals(task.getTaskType()))return false; + ||!java.util.Objects.equals(account,task.getSourceAccount()))return false; if(task.getId()!=null&&!seen.add(task.getId()))return false; + if(allowContinuous&&continuousBusinessRoot(task))return true; + if("SCHEDULED".equals(task.getTaskType())&&(!allowContinuous||task.getUpstreamTaskId()==null))return false; String key=task.getRequestKey(); if(key!=null&&key.startsWith("manual:")&&task.getRequestedBy()!=null&&!task.getRequestedBy().isBlank())return true; Long parent=task.getUpstreamTaskId(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java new file mode 100644 index 000000000..3a78b484a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Immutable union captured under the warehouse scope lock in the candidate's first transaction. */ +public final class SelfopSalesCoverage { + public static final String KEY="salesCoverageV1"; + private SelfopSalesCoverage() { } + public record Interval(LocalDateTime from,LocalDateTime until) { } + public record Evidence(Long runId,Long scopeId,String scopeHash,List intervals) { } + public static Map settings(String snapshot){ + Map result=new LinkedHashMap<>(); + if(snapshot!=null)read(snapshot,Map.class).forEach((k,v)->result.put(String.valueOf(k),v)); + result.remove(KEY);return result; + } + public static void freeze(SelfopSyncRun run,List intervals){ + validate(intervals);var snapshot=settings(run.getScopeSnapshotJson()); + snapshot.put(KEY,new Evidence(run.getId(),run.getScopeId(),run.getScopeHash(),List.copyOf(intervals))); + run.setScopeSnapshotJson(json(snapshot)); + } + public static Evidence evidence(SelfopSyncRun run){ + if(run==null||run.getScopeSnapshotJson()==null)return null; + Object value=read(run.getScopeSnapshotJson(),Map.class).get(KEY);if(value==null)return null; + var evidence=read(json(value),Evidence.class); + require(Objects.equals(run.getId(),evidence.runId())&&Objects.equals(run.getScopeId(),evidence.scopeId()) + &&Objects.equals(run.getScopeHash(),evidence.scopeHash()),"SELFOP_COVERAGE_VERSION_INVALID"); + validate(evidence.intervals());return evidence; + } + public static boolean contains(Evidence evidence,LocalDateTime from,LocalDateTime until){ + return evidence!=null&&from!=null&&until!=null&&from.isBefore(until)&&evidence.intervals().stream() + .anyMatch(i->!i.from().isAfter(from)&&!i.until().isBefore(until)); + } + public static void requireTask(SelfopSyncRun run,SelfopSyncTask task){ + var proof=evidence(run);require(proof!=null&&!proof.intervals().isEmpty(),"SELFOP_COVERAGE_UNPROVEN"); + if(task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null) + require(contains(proof,task.getQueryFromUtc(),task.getQueryUntilUtc()),"SELFOP_TASK_COVERAGE_UNPROVEN"); + } + private static void validate(List intervals){ + require(intervals!=null&&intervals.size()<=500,"SELFOP_COVERAGE_INTERVAL_LIMIT"); + LocalDateTime last=null; + for(var i:intervals){require(i!=null&&i.from()!=null&&i.until()!=null&&i.from().isBefore(i.until()) + &&(last==null||last.isBefore(i.from())),"SELFOP_COVERAGE_INTERVAL_INVALID");last=i.until();} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java index 276066eb3..c2911d5f1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java @@ -11,7 +11,7 @@ import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; public final class SelfopStageEstimate { private SelfopStageEstimate() { } public static final int WINDOW_SECONDS=180, MIN_SECONDS=60, MIN_ADVANCES=3, - BUCKET_SECONDS=10, MAX_POINTS=20, STALE_SECONDS=60, VALID_SECONDS=10, MAX_BYTES=1024; + BUCKET_SECONDS=10, MAX_POINTS=20, STALE_SECONDS=60, VALID_SECONDS=30, MAX_BYTES=1024; public record Estimate(String status,String progressIdentity,Long completed,Long total, Instant sampleFrom,Instant sampleUntil,Long completedDelta,BigDecimal ratePerSecond, Long remainingSeconds,Instant estimatedAt,Instant validUntil,String unavailableReason) { } @@ -23,7 +23,7 @@ public final class SelfopStageEstimate { return hash(values); } - /** A reuse/uncertain checkpoint resets the baseline, never contributes throughput. */ + /** Reused count increases reset the baseline; unchanged bookkeeping is not new work. */ public static void checkpoint(Map p,Instant now,boolean realWork) { try { Long completed=number(p.get("completed")),total=number(p.get("total")); @@ -32,7 +32,8 @@ public final class SelfopStageEstimate { } String id=identity(p);long second=now.getEpochSecond(); var old=map(p.get("etaSamples"));var points=points(old.get("p")); - if(!id.equals(old.get("i"))||!realWork||points.isEmpty() + if(!id.equals(old.get("i"))||points.isEmpty() + ||!realWork&&!completed.equals(points.get(points.size()-1).get(1)) ||second=STALE_SECONDS)points.clear(); @@ -66,7 +67,9 @@ public final class SelfopStageEstimate { if(reason==null&&(!id.equals(sample.get("i"))||points.isEmpty()))reason="SAMPLING"; if(reason==null) { long second=now.getEpochSecond(),lastAdvance=numberOr(sample.get("a"),0); - points.removeIf(point->second-point.get(0)>WINDOW_SECONDS); + // Select an anchor that stays inside the window through the read validity + // period, instead of expiring each time the oldest 10-second bucket rolls out. + points.removeIf(point->second-point.get(0)>WINDOW_SECONDS-VALID_SECONDS); if(points.isEmpty()||second-lastAdvance>=STALE_SECONDS)reason="NO_RECENT_PROGRESS"; else { var first=points.get(0);var last=points.get(points.size()-1); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java index a95b1f0e9..210966b74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java @@ -9,6 +9,22 @@ import java.util.List; /** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */ @Mapper public interface OutboundAggregateMapper { + @Select(""" + SELECT MIN(from_at) AS covered_from_utc,MAX(until_at) AS covered_until_utc FROM ( + SELECT from_at,until_at,SUM(CASE WHEN previous_until IS NULL OR from_at>previous_until THEN 1 ELSE 0 END) + OVER (ORDER BY from_at,until_at ROWS UNBOUNDED PRECEDING) AS island FROM ( + SELECT from_at,until_at,MAX(until_at) OVER + (ORDER BY from_at,until_at ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS previous_until FROM ( + SELECT query_from_utc AS from_at,query_until_utc AS until_at FROM ll_selfop_sync_task + WHERE scope_id=#{scopeId} AND scope_hash=#{scopeHash} AND execution_purpose='BUSINESS' + AND validation_root_task_id IS NULL AND task_role='WAREHOUSE_SYNC' AND apply_state='APPLIED' + AND apply_completed_at_utc IS NOT NULL AND query_from_utc IS NOT NULL AND query_until_utc>query_from_utc + AND JSON_UNQUOTE(JSON_EXTRACT(routing_manifest_json,'$.directoryComplete'))='true' + ) accepted + ) ordered_intervals + ) islands GROUP BY island ORDER BY MIN(from_at) LIMIT 501 + """) + List provenCoverageIslands(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash); @Select(""" SELECT COUNT(*) FROM ll_selfop_outbound_order o LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java index a4e0e95ae..3c9802f5f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java @@ -53,6 +53,7 @@ public class OutboundCandidateTransaction { OutboundPolicy.DayCoverage days=OutboundPolicy.completeDays(coverage.from(),coverage.until(),scope.getBusinessTimezone()); run.setCompleteDayFrom(days.from());run.setCompleteDayUntil(days.until());run.setCoverageStatus(coverage.until()==null?"UNKNOWN":"COMPLETE"); run.setCalculatedFactRevision(scope.getFactRevision());run.setBuildStartedAtUtc(SelfopSupport.utcNow());run.setStatus("BUILDING"); + SelfopSalesCoverage.freeze(run,provenIslands(scope)); Checkpoint result=new Checkpoint(runId,"DAILY",null,null,null,0,0,0);save(held,result);return result; } @@ -106,13 +107,20 @@ public class OutboundCandidateTransaction { if(published==null || !Boolean.TRUE.equals(published.getWarehouseDailyReady()) || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose()) || published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash()) || !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision()) - || !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null; + || !Objects.equals(SelfopSalesCoverage.settings(held.run().getScopeSnapshotJson()),SelfopSalesCoverage.settings(published.getScopeSnapshotJson())))return null; + var proof=SelfopSalesCoverage.evidence(published); + if(proof==null||!Objects.equals(proof.intervals(),provenIslands(scope)))return null; OutboundCandidateBuilder.Coverage next=coverage(scope); if(!Objects.equals(next.from(),published.getCoveredFromUtc()) || !Objects.equals(next.until(),published.getCoveredUntilUtc()))return null; // Content-identical accepted details still change receipt/run evidence; conservatively rebuild them. return aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),published.getBuildStartedAtUtc())==0?published.getId():null; } + private List provenIslands(SelfopSyncScope scope){ + var rows=aggregates.provenCoverageIslands(scope.getId(),scope.getScopeHash()); + SelfopSupport.require(rows.size()<=500,"SELFOP_COVERAGE_INTERVAL_LIMIT"); + return rows.stream().map(r->new SelfopSalesCoverage.Interval(r.getCoveredFromUtc(),r.getCoveredUntilUtc())).toList(); + } private Held held(Long scopeId,Long runId,String owner,long fence,boolean building) { SelfopSyncScope scope=locks.scope(scopeId);LocalDateTime now=SelfopSupport.utcNow(); SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()) && Objects.equals(scope.getLeaseOwner(),owner) && Objects.equals(scope.getFenceToken(),fence) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java index 80a10caac..af36ee5a3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java @@ -48,11 +48,14 @@ public class AnalyticsQueryService { if(!Objects.equals(unit,row.quantityUnit()))valid=false; } Instant evidence=earliest(sp,ip); + var coverage=SalesQueryCoverage.intervals(s.sales); List evaluated=new ArrayList<>(); List points=new ArrayList<>(); for(LocalDate day=s.range.startDate();day.isBefore(s.range.endDateExclusive());day=day.plusDays(1)){ Daily row=daily.get(day); - var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,s.fullFrom,s.fullUntil, - s.context.salesCoveredFrom(),s.context.salesCoveredUntil(),evidence,row==null?null:row.outboundQuantity(), + var covered=SalesQueryCoverage.at(row!=null||evidence==null?coverage: + coverage.stream().filter(i->i.until().toInstant(ZoneOffset.UTC).isAfter(evidence)).toList(),day); + var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,SalesQueryCoverage.fullFrom(covered),SalesQueryCoverage.fullUntil(covered), + covered==null?null:utc(covered.from()),covered==null?null:utc(covered.until()),evidence,row==null?null:row.outboundQuantity(), row==null?null:row.outboundOrderCount(),valid,s.sourceKnown,s.context.rebuildRequired(),"STALE".equals(s.context.salesStatus()))); evaluated.add(point); points.add(new AnalyticsResponses.Point(day,point.outboundQuantity(),point.observedQuantity(),point.dataStatus(), @@ -113,7 +116,8 @@ public class AnalyticsQueryService { null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses)); } private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;} - private static LocalDate historyStart(Snapshot s){return s.context.salesCoveredFrom()==null?null:s.context.salesCoveredFrom().atZone(BUSINESS_ZONE).toLocalDate();} + private static LocalDate historyStart(Snapshot s){return SalesQueryCoverage.intervals(s.sales).stream() + .map(i->utc(i.from()).atZone(BUSINESS_ZONE).toLocalDate()).min(LocalDate::compareTo).orElse(null);} private static BigDecimal divide30(BigDecimal n){return n==null?null:n.divide(BigDecimal.valueOf(30),12,java.math.RoundingMode.HALF_UP);} private static BigDecimal decimal(Map row,String key){Object n=row.get(key);return n==null?null:n instanceof BigDecimal d?d:new BigDecimal(n.toString());} private static long number(Map row,String key){BigDecimal n=decimal(row,key);return n==null?0:n.longValueExact();} @@ -162,11 +166,12 @@ public class AnalyticsQueryService { if(!"VALID".equals(result.status()))reasons.add(result.status()); if(r.inventoryReason()!=null)reasons.add(r.inventoryReason()); BigDecimal today=r.todayQuantity(); - if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){ + if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null){ Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant(); + Instant end=s.range.anchorDate().plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant(); Instant lower=evidence.isAfter(start)?evidence:start; - if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom(); - if(lower.isBefore(s.context.salesCoveredUntil()))today=BigDecimal.ZERO; + if(lower.isBefore(end)&&SalesQueryCoverage.intervals(s.sales).stream() + .anyMatch(i->utc(i.from()).isBefore(end)&&utc(i.until()).isAfter(lower)))today=BigDecimal.ZERO; } return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(), r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null, @@ -207,7 +212,8 @@ public class AnalyticsQueryService { boolean rebuild=salesScope!=null&&(Boolean.TRUE.equals(salesScope.rebuildRequired()) ||!Objects.equals(salesScope.factRevision(),salesScope.publishedFactRevision())||mapper.applyBarriers(q.sourceAccount(),q.warehouseCode())>0); boolean known=sales!=null&&BUSINESS_ZONE.getId().equals(sc.path("sourceTimezone").asText()); - String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(sales.coveredUntilUtc(),threshold(sc))?"STALE":"VALID"; + LocalDateTime latestCoverage=SalesQueryCoverage.intervals(sales).stream().map(i->i.until()).max(LocalDateTime::compareTo).orElse(null); + String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(latestCoverage,threshold(sc))?"STALE":"VALID"; if(rebuild)salesState="REBUILD_REQUIRED"; String inventoryState=inventory==null?"UNKNOWN":stale(inventory.fetchFinishedAtUtc(),threshold(ic))?"STALE":"VALID"; LocalDate fullFrom=sales==null?null:sales.completeDayFrom(),fullUntil=sales==null?null:sales.completeDayUntil(); @@ -240,7 +246,7 @@ public class AnalyticsQueryService { s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()), s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown, s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()), - s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind); + s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind,SalesQueryCoverage.intervals(s.sales)); } Instant inventoryFreshUntil(Snapshot s){ return s.inventory()==null||s.inventory().fetchFinishedAtUtc()==null?null: @@ -255,12 +261,12 @@ public class AnalyticsQueryService { ||!scope.warehouseCode().equals(run.warehouseCode())||!scope.scopeHash().equals(run.scopeHash()) ||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status())) throw new BizException("版本不可用或范围不符,请刷新整页"); - if(!"COMPLETE".equals(run.coverageStatus()))throw new BizException("发布版本缺少完整覆盖证明"); + if(!"COMPLETE".equals(run.coverageStatus())&&SalesQueryCoverage.intervals(run).isEmpty())throw new BizException("发布版本缺少完整覆盖证明"); return run; } private Profile profile(Snapshot s,Run run){return run==null?null:mapper.profile(run.id(),s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku());} - private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&s.sales!=null&&s.sales.coveredFromUtc()!=null&&s.sales.coveredUntilUtc()!=null - &&s.sales.coveredFromUtc().isBefore(s.sales.coveredUntilUtc())&&s.fullFrom!=null&&s.fullUntil!=null&&!from.isBefore(s.fullFrom)&&!until.isAfter(s.fullUntil);} + private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&!until.isAfter(s.range.anchorDate()) + &&SalesQueryCoverage.complete(SalesQueryCoverage.intervals(s.sales),from,until);} private JsonNode config(Run r){if(r==null)return json.createObjectNode();try{JsonNode n=json.readTree(r.scopeSnapshotJson());if(n==null||!n.isObject())throw new IllegalArgumentException();return n;}catch(Exception e){throw new BizException("版本配置快照无效");}} private static int threshold(JsonNode c){int n=c.path("staleAfterSeconds").asInt();if(n<=0)throw new BizException("版本过期阈值无效");return n;} private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java index 61ef17203..221c70bf1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java @@ -30,6 +30,7 @@ public final class AnalyticsReadSql { } private static String cte(AnalyticsSqlRequest request){ String kind=request.kind(); + if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request); if("HOT".equals(kind))return hotSalesCte(); if("SLOW".equals(kind))return rankingCte(); String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; @@ -152,6 +153,35 @@ public final class AnalyticsReadSql { } return sql; } + private static String coveredSummary(AnalyticsSqlRequest p){ + var intervals=new java.util.ArrayList(); + for(int i=0;i(); + for(var day=p.from();day.isBefore(p.until());day=day.plusDays(1))dates.add("SELECT CAST('"+day+"' AS DATE) business_date"); + String start="TIMESTAMPADD(HOUR,-8,CAST(calendar.business_date AS DATETIME))",end="TIMESTAMPADD(DAY,1,"+start+")"; + String full="EXISTS (SELECT 1 FROM coverage v WHERE v.lo<="+start+" AND v.hi>="+end+")"; + String observed="EXISTS (SELECT 1 FROM coverage v WHERE GREATEST(v.lo,b.evidence_at_utc,"+start+")=#{p.from} AND business_date<#{p.until} GROUP BY customer_code,sku_code)," + +" rankable AS (SELECT e.*,CASE WHEN e.profile_unit_valid=1 AND (s.min_unit IS NULL OR (s.min_unit=e.quantity_unit AND s.max_unit=e.quantity_unit AND s.min_qty>=0)) THEN 1 ELSE 0 END unit_valid," + +"CASE WHEN #{p.salesKnown}=1 AND e.profile_unit_valid=1 AND (s.min_unit IS NULL OR (s.min_unit=e.quantity_unit AND s.max_unit=e.quantity_unit AND s.min_qty>=0))" + +" AND (s.observed_sum IS NOT NULL OR EXISTS (SELECT 1 FROM coverage v WHERE GREATEST(v.lo,e.evidence_at_utc,TIMESTAMPADD(HOUR,-8,CAST(#{p.from} AS DATETIME))) coverage) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java index 7c59b955f..02af43091 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java @@ -105,7 +105,7 @@ public class CollectionQueryService { String freshness=published==null?"NOT_COLLECTED":scope.staleAfterSeconds()==null||scope.staleAfterSeconds()<=0?"UNKNOWN" :observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH"; rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked, - enabled&&"production".equals(config.getEnvironment())?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()), + enabled&&("production".equals(config.getEnvironment())||continuousBusiness())?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()), scope==null?null:utc(scope.initialUntilUtc()),initial,published==null?null:utc(published.coveredFromUtc()), published==null?null:utc(published.coveredUntilUtc()),last==null?null:last.taskNo(),last==null||context.latest().get(last.id())==null?null:utc(context.latest().get(last.id()).finishedAtUtc()), published==null?null:utc(published.publishedAtUtc()),"INVENTORY".equals(family)&&published!=null?utc(published.fetchStartedAtUtc()):null, @@ -234,7 +234,12 @@ public class CollectionQueryService { resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()), blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()), validation(root==null?t:root),attempt,dependency,waiting, - "WAITING_CONFIRMED".equals(dependency)?parent.taskNo():t.taskNo(),stageEstimate(t,latest,attempt,waiting)); + "WAITING_CONFIRMED".equals(dependency)?parent.taskNo():t.taskNo(),stageEstimate(t,latest,attempt,waiting),utc(t.createdAt()),taskElapsed(t)); + } + private Long taskElapsed(Task task) { + Instant start=utc(task.createdAt()); + Instant end=ACTIVE.contains(task.status())?clock.instant():utc(task.updatedAt()); + return start==null||end==null||end.isBefore(start)?null:Duration.between(start,end).getSeconds(); } private String dependencyState(Task task,Task parent) { if(task.upstreamTaskId()==null)return "WAITING_SOURCE".equals(task.status())?"WAITING_UNCONFIRMED":"NONE"; @@ -280,7 +285,7 @@ public class CollectionQueryService { if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK"; String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled; if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS"; - if("test".equals(config.getEnvironment())&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED"; + if("test".equals(config.getEnvironment())&&!continuousBusiness()&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED"; if("test".equals(config.getEnvironment())) { String budgetBlock=businessBudgetBlock(task);if(budgetBlock!=null)return budgetBlock; } @@ -295,13 +300,16 @@ public class CollectionQueryService { JsonNode budget=boundedObject(task.businessRootBudgetJson()); Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp"); Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc"); + boolean continuous=budget!=null&&budget.path("continuous").isBoolean()&&budget.path("continuous").booleanValue(); + if(continuous&&!continuousBusiness())return "CONTINUOUS_COLLECTION_DISABLED"; if(budget==null||maximum==null||maximum<0||maximum>Integer.MAX_VALUE||reserved==null||reserved>Integer.MAX_VALUE - ||confirmed==null||confirmed>reserved||started==null||deadline==null||!deadline.isAfter(started)) + ||confirmed==null||confirmed>reserved||started==null + ||(continuous?budget.hasNonNull("deadlineAtUtc"):deadline==null||!deadline.isAfter(started))) return "BUSINESS_SESSION_BUDGET_MISSING"; JsonNode stopped=budget.get("stopReason"); if(stopped!=null&&!stopped.isNull()) return "BUSINESS_SESSION_STOPPED"; - if(!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE"; + if(!continuous&&!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE"; return null; } private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) { @@ -346,14 +354,21 @@ public class CollectionQueryService { if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED"; if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() - ||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID"; + ||(!continuousBusiness()&&(config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))))return "EXCLUSIVE_GRANT_NOT_VALID"; if("BUSINESS".equals(config.getCollectionMode()))return null; if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED"; if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate()) &&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED"; return null; } + private boolean continuousBusiness() { + return com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.continuousBusiness(config); + } private Instant nextSchedule(String warehouse,String family) { + if(continuousBusiness()&&"OUTBOUND".equals(family)) { + var slot=clock.instant().atZone(BUSINESS_ZONE).toLocalDate().atStartOfDay(BUSINESS_ZONE).plusMinutes(5); + return (slot.toInstant().isAfter(clock.instant())?slot:slot.plusDays(1)).toInstant(); + } var w=config.getWarehouses().get(warehouse); try {var next=CronExpression.parse("OUTBOUND".equals(family)?w.getOutboundCron():w.getInventoryCron()).next(clock.instant().atZone(BUSINESS_ZONE));return next==null?null:next.toInstant();} catch(IllegalArgumentException ex){return null;} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java index 8bc333f9a..7d09b63ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java @@ -31,7 +31,7 @@ public final class CollectionResponses { List allowedActions, String actionBlockReason, Instant progressUpdatedAt, Map progress, Validation validation, Attempt latestAttempt, String dependencyState, String blockingReason, String progressSourceTaskNo, - SelfopStageEstimate.Estimate stageEstimate) {} + SelfopStageEstimate.Estimate stageEstimate, Instant createdAt, Long elapsedSeconds) {} public record TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {} public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase, String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java index 968fccded..6bfeadc03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java @@ -22,18 +22,18 @@ public class DashboardQueryService { throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询"); var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate(); var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom, - base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD"); + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD",base.coverage()); var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null); var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive()); var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList(); Map stats=report.get(0);var ranked=report.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); - var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days); + var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days,SalesQueryCoverage.intervals(snapshot.sales())); var trendSummary=SalesBucketPolicy.aggregate(trendPoints); - var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days);var fixed=SalesBucketPolicy.aggregate(fixedPoints); + var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days,SalesQueryCoverage.intervals(snapshot.sales()));var fixed=SalesBucketPolicy.aggregate(fixedPoints); Map hotStats=stats;List hotRanked=ranked; if(!c.rangeStartDate().equals(fixedFrom)||!c.rangeEndDateExclusive().equals(fixedUntil)||!base.complete30()){ var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),fixedFrom, - base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD"); + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD",base.coverage()); var hotReport=mapper.report(hotRequest,o).stream().map(DashboardQueryService::normalize).toList(); hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java index 721dbff5c..69a62064e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java @@ -8,20 +8,23 @@ import java.util.*; /** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */ public final class DashboardSalesPolicy { private DashboardSalesPolicy(){} - public static List points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List rows){ + public static List points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List rows, + List coverage){ + var intervals=coverage.isEmpty()?SalesQueryCoverage.scalar(c.salesCoveredFrom()==null?null:LocalDateTime.ofInstant(c.salesCoveredFrom(),ZoneOffset.UTC), + c.salesCoveredUntil()==null?null:LocalDateTime.ofInstant(c.salesCoveredUntil(),ZoneOffset.UTC)):coverage; Map days=new HashMap<>();for(var row:rows)days.put(row.businessDate(),row); List result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai"); for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1)){ var actual=days.get(day);Instant start=day.atStartOfDay(zone).toInstant(),end=day.plusDays(1).atStartOfDay(zone).toInstant(); - Instant lower=c.salesCoveredFrom()==null?null:(c.salesCoveredFrom().isAfter(start)?c.salesCoveredFrom():start); - Instant upper=c.salesCoveredUntil()==null?null:(c.salesCoveredUntil().isBefore(end)?c.salesCoveredUntil():end); + var interval=SalesQueryCoverage.at(intervals,day); + Instant lower=interval==null?null:(interval.from().toInstant(ZoneOffset.UTC).isAfter(start)?interval.from().toInstant(ZoneOffset.UTC):start); + Instant upper=interval==null?null:(interval.until().toInstant(ZoneOffset.UTC).isBefore(end)?interval.until().toInstant(ZoneOffset.UTC):end); boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper); boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0; // Global recency describes how far collection has progressed, not whether an // already published historical day has complete coverage. Keep STALE on context. boolean trusted=("VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus()))&&!c.rebuildRequired(); - boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&c.completeDayFrom()!=null&&c.completeDayUntil()!=null - &&!day.isBefore(c.completeDayFrom())&&day.isBefore(c.completeDayUntil())&&intersects&&lower.equals(start)&&upper.equals(end); + boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&SalesQueryCoverage.complete(intervals,day,day.plusDays(1)); BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null; if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO; String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List reasons=new ArrayList<>(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java new file mode 100644 index 000000000..763feb99d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.time.*; +import java.util.*; + +/** Reads only immutable evidence already stored in the selected published run. */ +public final class SalesQueryCoverage { + private SalesQueryCoverage() { } + public static List intervals(AnalyticsReadRows.Run selected){ + if(selected==null)return List.of(); + var run=new SelfopSyncRun();run.setId(selected.id());run.setScopeId(selected.scopeId());run.setScopeHash(selected.scopeHash());run.setScopeSnapshotJson(selected.scopeSnapshotJson()); + var evidence=SelfopSalesCoverage.evidence(run); + if(evidence!=null)return evidence.intervals(); + // The existing scalar is itself a frozen continuous proof; never extend it from live tasks. + return scalar(selected.coveredFromUtc(),selected.coveredUntilUtc()); + } + public static List scalar(LocalDateTime from,LocalDateTime until){ + return from!=null&&until!=null&&from.isBefore(until)?List.of(new SelfopSalesCoverage.Interval(from,until)):List.of(); + } + public static SelfopSalesCoverage.Interval at(List intervals,LocalDate date){ + var start=date.atStartOfDay().minusHours(8);var end=start.plusDays(1); + return intervals.stream().filter(i->i.from().isBefore(end)&&i.until().isAfter(start)).findFirst().orElse(null); + } + public static boolean complete(List intervals,LocalDate from,LocalDate until){ + var start=from.atStartOfDay().minusHours(8);var end=until.atStartOfDay().minusHours(8); + return start.isBefore(end)&&intervals.stream().anyMatch(i->!i.from().isAfter(start)&&!i.until().isBefore(end)); + } + public static LocalDate fullFrom(SelfopSalesCoverage.Interval i){ + if(i==null)return null;var local=i.from().plusHours(8);return local.toLocalTime().equals(LocalTime.MIDNIGHT)?local.toLocalDate():local.toLocalDate().plusDays(1); + } + public static LocalDate fullUntil(SelfopSalesCoverage.Interval i){return i==null?null:i.until().plusHours(8).toLocalDate();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java index eea884254..ec352f292 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java @@ -8,6 +8,7 @@ public final class SelfopBusinessBudget { /** Recorded field only; BUSINESS has no HTTP-count ceiling. New sessions write zero. */ public int maxHttp; public int http, confirmedHttp; + public boolean continuous; public LocalDateTime startedAtUtc, deadlineAtUtc; public String stopReason; @@ -20,9 +21,15 @@ public final class SelfopBusinessBudget { return SelfopSupport.json(budget); } + public static String freezeContinuous(LocalDateTime now) { + if(now==null)throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID"); + var budget=new SelfopBusinessBudget();budget.startedAtUtc=now;budget.continuous=true; + return SelfopSupport.json(budget); + } + public void validate() { if (http < 0 || confirmedHttp < 0 || confirmedHttp > http - || startedAtUtc == null || deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc) + || startedAtUtc == null || (continuous ? deadlineAtUtc != null : deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc)) || stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}")) throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null); } @@ -30,7 +37,7 @@ public final class SelfopBusinessBudget { public String limit(LocalDateTime now) { validate(); if (stopReason != null) return stopReason; - if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; + if (!continuous && !now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; return null; } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index 5e27e99c5..ebf61f6ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -18,9 +18,9 @@ public class SelfopEgressService { if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID"); LocalDateTime now=now();gate(context.sourceAccount(),context.executionPurpose(),context.exclusiveGrantId(),now); if(operation==null||requestHash==null||!requestHash.matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_INVALID"); - var account=account(context.sourceAccount());var budget=budget(account,now);save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount()); + var account=account(context.sourceAccount());var budget=budget(account,now,context.executionPurpose());save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount()); var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId()); - SelfopBusinessBudget business=testBusiness(context.executionPurpose())?business(root):null; + SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null; if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}} verify(task,run,root,account,now); checkAccount(account,budget,now); @@ -42,9 +42,9 @@ public class SelfopEgressService { if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); - if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); + if(!continuousBusiness(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; - if(business!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.deadlineAtUtc; + if(business!=null&&business.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.deadlineAtUtc; budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now; if(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);} if(business!=null){business.http++;saveBusiness(root,business);} @@ -53,15 +53,15 @@ public class SelfopEgressService { @Transactional(noRollbackFor=EgressRejectedException.class) public AttemptPermit authorize(AuthorizationRequest request){ requireRequest(request);var c=request.context();var now=now();gate(c.sourceAccount(),c.executionPurpose(),c.exclusiveGrantId(),now); - var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pendingForAuthorization(budget,c,request.operation()); + var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pendingForAuthorization(budget,c,request.operation()); var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); - if(testBusiness(c.executionPurpose())){ + if(hasBusinessSession(root)){ var b=business(root);verifyBusinessTask(task,root); // Authorization preserves the root time/stop fence independently of request accounting. - String limit=b.stopReason!=null?b.stopReason:!now.isBefore(b.deadlineAtUtc)?"BUSINESS_SESSION_DEADLINE":null; + String limit=b.limit(now); if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);} - if(pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc; + if(b.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc; } verify(task,run,root,account,now); if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); @@ -74,7 +74,7 @@ public class SelfopEgressService { @Transactional(noRollbackFor=EgressRejectedException.class) public void complete(CompletionRequest request){ if(request==null||request.context()==null||request.actualHttpAttempts()<0||request.actualHttpAttempts()>1)throw reject("EGRESS_COMPLETION_INVALID"); - var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pending(budget,c,request.operation(),request.requestHash()); + var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pending(budget,c,request.operation(),request.requestHash()); String completionHash=SelfopSupport.hash(request); if(pending.completed&&!pending.abandoned){ if(!Objects.equals(pending.completionHash,completionHash))throw reject("EGRESS_COMPLETION_CONFLICT"); @@ -85,7 +85,8 @@ public class SelfopEgressService { var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); SelfopValidationBudget validation="VALIDATION".equals(c.executionPurpose())?validation(root):null; if(validation!=null){validation.confirmedHttp+=request.actualHttpAttempts();root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);} - if(testBusiness(c.executionPurpose())){var b=business(root);b.confirmedHttp+=request.actualHttpAttempts();b.validate();saveBusiness(root,b);} + // An already authorized response remains accountable after continuous mode is disabled. + if(hasBusinessSession(root)){var b=business(root);b.confirmedHttp+=request.actualHttpAttempts();b.validate();saveBusiness(root,b);} boolean auth=Integer.valueOf(401).equals(request.upstreamStatus())||Integer.valueOf(403).equals(request.upstreamStatus()) ||"LINGXING_WMS_AUTHORIZATION_REJECTED".equals(request.errorCode())||"LINGXING_WMS_SIGNATURE_REJECTED".equals(request.errorCode()); boolean rate=!auth&&(Integer.valueOf(429).equals(request.upstreamStatus())||Integer.valueOf(31001).equals(request.sourceCode()) @@ -126,16 +127,21 @@ public class SelfopEgressService { if("OFF".equals(config.getCollectionMode())||!Objects.equals(config.getCollectionMode(),purpose)||!("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose)))throw reject("EGRESS_MODE_REJECTED"); if("BUSINESS".equals(purpose)&&!("production".equals(config.getEnvironment())||"test".equals(config.getEnvironment())))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH"); - if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + if(grant==null||grant.isBlank()||!Objects.equals(grant,config.getExclusiveGrantId())||(!continuousBusiness(purpose)&&(config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())))||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); if(("VALIDATION".equals(purpose)&&config.getDailyRequestBudget()<1)||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); } private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;} - private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){ + private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now,String purpose){ var b=account.getApiBudgetJson()==null?new SelfopApiBudget():SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class); if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID"); LocalDate day=now.plusHours(8).toLocalDate(); - if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;} - if("BUSINESS".equals(config.getCollectionMode()))b.dailyLimit=0; + if(!Objects.equals(day,b.budgetDate)){ + b.budgetDate=day; + // BUSINESS records cumulative consumption, including transferred and uncertain attempts. + // Only the finite VALIDATION daily quota rolls over; cooling/pending always survive. + if(!"BUSINESS".equals(purpose)){b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;} + } + if("BUSINESS".equals(purpose))b.dailyLimit=0; else {if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget();b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());} b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs()); b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds())); @@ -161,20 +167,23 @@ public class SelfopEgressService { } private SelfopSyncTask root(Long id,String purpose,String account){var t=mapper.lockTask(id);if(t==null||!Objects.equals(t.getSourceAccount(),account)||!Objects.equals(t.getExecutionPurpose(),purpose))throw reject("EGRESS_ROOT_INVALID");if("VALIDATION".equals(purpose)&&!Objects.equals(id,t.getValidationRootTaskId()))throw reject("VALIDATION_ROOT_INVALID");return t;} private void verify(SelfopSyncTask task,SelfopSyncRun run,SelfopSyncTask root,SelfopOutboundDiscoveryScope account,LocalDateTime now){ - if(testBusiness(root.getExecutionPurpose())&&"DEAD".equals(root.getStatus()))throw reject("BUSINESS_SESSION_ROOT_STOPPED"); + if(hasBusinessSession(root)&&"DEAD".equals(root.getStatus()))throw reject("BUSINESS_SESSION_ROOT_STOPPED"); if(task==null||run==null||!Objects.equals(run.getTaskId(),task.getId())||!Objects.equals(run.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getExecutionPurpose(),root.getExecutionPurpose())||!Objects.equals(run.getExecutionPurpose(),task.getExecutionPurpose())||!"RUNNING".equals(task.getStatus())||!Set.of("FETCHING","VALIDATED").contains(run.getStatus()))throw reject("EGRESS_TASK_NOT_ACTIVE"); if("VALIDATION".equals(root.getExecutionPurpose())&&(!Objects.equals(task.getValidationRootTaskId(),root.getId())||Set.of("DEAD","SAMPLE_DONE").contains(root.getStatus())))throw reject("VALIDATION_ROOT_STOPPED"); if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){if(!Objects.equals(task.getDiscoveryScopeId(),account.getId())||!Objects.equals(account.getFenceToken(),run.getFenceToken())||account.getLeaseUntilUtc()==null||!now.isBefore(account.getLeaseUntilUtc()))throw reject("EGRESS_FENCE_LOST");} else if(!Objects.equals(task.getScopeId(),run.getScopeId())||!Objects.equals(task.getWarehouseCode(),run.getWarehouseCode())||mapper.warehouseFence(task.getScopeId(),run.getFenceToken(),task.getId(),now)!=1)throw reject("EGRESS_FENCE_LOST"); } private SelfopValidationBudget validation(SelfopSyncTask root){if(root==null||!"VALIDATION".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getValidationRootTaskId())||root.getValidationBudgetJson()==null)throw reject("VALIDATION_ROOT_INVALID");var b=SelfopSupport.read(root.getValidationBudgetJson(),SelfopValidationBudget.class);b.validate();return b;} + private boolean continuousBusiness(String purpose){return "BUSINESS".equals(purpose)&&config.isContinuousBusinessCollectionEnabled();} private boolean testBusiness(String purpose){return "test".equals(config.getEnvironment())&&"BUSINESS".equals(purpose);} + private boolean hasBusinessSession(SelfopSyncTask root){return "BUSINESS".equals(root.getExecutionPurpose())&&(testBusiness(root.getExecutionPurpose())||root.getBusinessBudgetJson()!=null||SelfopCollectionPolicy.continuousBusinessRoot(root));} private SelfopBusinessBudget business(SelfopSyncTask root){ if(root==null||!"BUSINESS".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getBusinessRootTaskId())||root.getBusinessBudgetJson()==null)throw reject("BUSINESS_SESSION_ROOT_INVALID"); SelfopBusinessBudget b;try{b=SelfopSupport.read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);}catch(RuntimeException e){throw reject("BUSINESS_SESSION_BUDGET_INVALID");} - if(b==null)throw reject("BUSINESS_SESSION_BUDGET_INVALID");b.validate();return b; + if(b==null)throw reject("BUSINESS_SESSION_BUDGET_INVALID");b.validate(); + if(b.continuous&&!SelfopCollectionPolicy.continuousBusinessRoot(root))throw reject("BUSINESS_SESSION_ROOT_INVALID");return b; } - private void verifyBusinessTask(SelfopSyncTask task,SelfopSyncTask root){if(task==null||!Objects.equals(task.getBusinessRootTaskId(),root.getId())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!"BUSINESS".equals(task.getExecutionPurpose()))throw reject("BUSINESS_SESSION_ROOT_INVALID");} + private void verifyBusinessTask(SelfopSyncTask task,SelfopSyncTask root){if(task==null||!Objects.equals(task.getBusinessRootTaskId(),root.getId())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!"BUSINESS".equals(task.getExecutionPurpose())||business(root).continuous&&!continuousBusiness(root.getExecutionPurpose()))throw reject("BUSINESS_SESSION_ROOT_INVALID");} private void stopBusiness(SelfopSyncTask root,SelfopBusinessBudget b,String reason,LocalDateTime now){ b.stopReason=reason;root.setStatus("DEAD");root.setNextRetryAtUtc(null);saveBusiness(root,b); mapper.terminateBusinessTasks(root.getId(),root.getSourceAccount());mapper.terminateBusinessRuns(root.getId(),root.getSourceAccount(),reason,now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java index d6038f892..b176d0e8d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java @@ -37,6 +37,7 @@ public class SelfopExecutionService { } public void execute(long taskId){ var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose()); + if(SelfopCollectionPolicy.continuousBusiness(config))require(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); if("SUCCEEDED".equals(task.getStatus())||"DEAD".equals(task.getStatus()))return; String owner=UUID.randomUUID().toString();Map held=new LinkedHashMap<>();SelfopLeaseService.Lease primary; if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){primary=leases.acquireDiscovery(task.getDiscoveryScopeId(),owner);held.put(primary.scopeId(),primary);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java index dbf480ded..90f8ec677 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java @@ -34,9 +34,9 @@ public class SelfopSalesPublisher { require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"HALF_APPLIED_FACTS"); require(Objects.equals(run.getCalculatedFactRevision(),scope.getFactRevision()),"SELFOP_CANDIDATE_OBSOLETE"); require(run.getBuildStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"SELFOP_CANDIDATE_OBSERVATION_CHANGED"); - require("COMPLETE".equals(run.getCoverageStatus())&&run.getCoveredFromUtc()!=null&&run.getCoveredUntilUtc()!=null,"SELFOP_COVERAGE_UNPROVEN"); var task=locks.lockTask(run.getTaskId());require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED"); SelfopCollectionPolicy.requireBusiness(task); + SelfopSalesCoverage.requireTask(run,task); if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() .eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now)); scope.setPublishedRunId(runId);scope.setPublishedFactRevision(run.getCalculatedFactRevision());scope.setRebuildRequired(0); @@ -59,6 +59,7 @@ public class SelfopSalesPublisher { require(Objects.equals(scope.getPublishedRunId(),publishedId)&&"PUBLISHED".equals(published.getStatus())&&Objects.equals(published.getCalculatedFactRevision(),scope.getFactRevision()) &&locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"SELFOP_REUSE_OBSOLETE"); require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED"); + SelfopSalesCoverage.requireTask(published,task); require(run.getFetchStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getFetchStartedAtUtc())==0,"SELFOP_REUSE_OBSERVATION_CHANGED"); var now=utcNow();run.setCalculatedFactRevision(published.getCalculatedFactRevision());SelfopProgressProjection.freezeRun(task,run);run.setStatus("REUSED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); task.setResultPublishedRunId(publishedId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java index 627f230d9..3503cc907 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java @@ -32,6 +32,15 @@ public class SelfopSchedulePlanner { public void tick(){ if("OFF".equals(config.getCollectionMode()))return; if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + if(SelfopCollectionPolicy.continuousBusiness(config)){ + planContinuous(utcNow()); + // One dispatch per tick: after a safe yield, re-plan and re-read priority. + for(var task:taskService.due(1)){ + if(!SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true))continue; + try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Durable lease owner continues. */} + } + return; + } List configured; if("VALIDATION".equals(config.getCollectionMode())){taskService.initializeAccount();configured=List.of();} else if("test".equals(config.getEnvironment()))configured=List.of(); @@ -48,6 +57,80 @@ public class SelfopSchedulePlanner { try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */} } } + public void planContinuous(LocalDateTime now){ + require(SelfopCollectionPolicy.continuousBusiness(config),"SELFOP_CONTINUOUS_COLLECTION_DISABLED"); + require("Asia/Shanghai".equals(config.getSourceQueryTimezone()),"SELFOP_SOURCE_TIMEZONE_REQUIRED"); + require(!config.getWarehouses().isEmpty(),"SELFOP_WAREHOUSE_UNCONFIGURED"); + var account=taskService.initializeAccount(); + tx.executeWithoutResult(s->{ + locks.lockDiscovery(account.getId()); + // Read existing scopes only. Pool creation must never move the published coverage anchor. + var configured=scopes.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("data_family","OUTBOUND").in("warehouse_code",config.getWarehouses().keySet())); + require(configured.size()==config.getWarehouses().size()&&configured.stream().allMatch(scope-> + "Asia/Shanghai".equals(scope.getSourceTimezone())&&"Asia/Shanghai".equals(scope.getBusinessTimezone())),"SELFOP_SCOPE_MISSING"); + var local=now.plusHours(8);var today=local.toLocalDate(); + var last=today.minusDays(local.toLocalTime().isBefore(LocalTime.of(0,5))?2:1); + var first=today.minusMonths(2); + String digest=hash(config.getWarehouses()); + var roots=tasks.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("execution_purpose","BUSINESS").eq("task_role","ACCOUNT_DISCOVERY").eq("task_type","BACKFILL") + .eq("discovery_scope_id",account.getId()).isNull("upstream_task_id").isNull("validation_root_task_id") + .ge("query_from_utc",first.atStartOfDay().minusHours(8)).le("query_until_utc",last.plusDays(1).atStartOfDay().minusHours(8))); + var acceptedHashes=new HashSet();acceptedHashes.add(digest); + configured.forEach(scope->acceptedHashes.add(scope.getScopeHash())); + for(var date=last;!date.isBefore(first);date=date.minusDays(1)){ + var from=date.atStartOfDay().minusHours(8);var until=from.plusDays(1); + String key="continuous:outbound:"+date+":"+digest.substring(0,12); + // Examine every root: an old DEAD attempt must not mask a later successful/manual root. + boolean exists=roots.stream().anyMatch(root->key.equals(root.getRequestKey())|| + (from.equals(root.getQueryFromUtc())&&until.equals(root.getQueryUntilUtc())&&acceptedHashes.contains(root.getScopeHash()) + &&Set.of("READY","RUNNING","RETRY_WAIT","SUCCEEDED").contains(root.getStatus())&&wholeDayQuery(root,from))); + if(exists)continue; + var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType("BACKFILL");task.setDiscoveryScopeId(account.getId()); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash(digest);task.setRequestKey(key); + task.setQueryFromUtc(from);task.setQueryUntilUtc(until); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from.plusHours(8).toString(),until.plusHours(8).toString(),null,null,null))); + task.setRequestHash(hash(Arrays.asList(task.getQueryScopeJson(),digest)));taskService.create(task); + } + planContinuousInventory(now,today); + }); + } + private void planContinuousInventory(LocalDateTime now,LocalDate today){ + var inventory=scopes.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("data_family","INVENTORY").in("warehouse_code",config.getWarehouses().keySet())); + require(inventory.size()==config.getWarehouses().size(),"SELFOP_SCOPE_MISSING"); + for(var scope:inventory){ + require("INVENTORY".equals(scope.getDataFamily())&&"Asia/Shanghai".equals(scope.getBusinessTimezone()),"SELFOP_INVENTORY_SCOPE_INVALID"); + var midnight=today.atStartOfDay(ZoneId.of("Asia/Shanghai")); + var slot=CronExpression.parse(config.getWarehouses().get(scope.getWarehouseCode()).getInventoryCron()).next(midnight.minusNanos(1)); + if(slot==null||!slot.toLocalDate().equals(today))continue; + var planned=slot.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + if(planned.isAfter(now))continue; + String key="continuous:inventory:"+scope.getWarehouseCode()+":"+planned+":"+scope.getScopeHash().substring(0,12); + var existing=tasks.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("scope_id",scope.getId()).eq("scope_hash",scope.getScopeHash()).eq("execution_purpose","BUSINESS") + .eq("task_role","WAREHOUSE_SYNC").eq("task_type","SCHEDULED") + .ge("planned_at_utc",midnight.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()) + .lt("planned_at_utc",midnight.plusDays(1).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime())); + if(existing.stream().anyMatch(t->key.equals(t.getRequestKey())|| + (Objects.equals(t.getScopeId(),scope.getId())&&Objects.equals(t.getScopeHash(),scope.getScopeHash()) + &&t.getPlannedAtUtc()!=null&&t.getPlannedAtUtc().plusHours(8).toLocalDate().equals(today) + &&SelfopCollectionPolicy.continuousBusinessRoot(t))))continue; + var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("WAREHOUSE_SYNC");task.setTaskType("SCHEDULED");task.setScopeId(scope.getId());task.setWarehouseCode(scope.getWarehouseCode()); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash(scope.getScopeHash());task.setRequestKey(key); + task.setPlannedAtUtc(planned);task.setNextRetryAtUtc(planned);task.setQueryScopeJson(scope.getScopeJson()); + task.setRequestHash(hash(Arrays.asList(scope.getScopeHash(),planned,task.getQueryScopeJson())));taskService.create(task); + } + } + private static boolean wholeDayQuery(SelfopSyncTask root,LocalDateTime from){ + try{var q=read(root.getQueryScopeJson(),SelfopDiscoveryService.Query.class); + return q!=null&&q.deliveryNo()==null&&q.customerCode()==null&&q.parentTaskId()==null + &&from.plusHours(8).equals(LocalDateTime.parse(q.startTime()))&&from.plusDays(1).plusHours(8).equals(LocalDateTime.parse(q.endTime())); + }catch(RuntimeException invalid){return false;} + } public void plan(SelfopSyncScope preview,LocalDateTime now){ SelfopCollectionPolicy.requireAutomaticPlanning(config); tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java index 9206dfb77..8effe516c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -110,15 +110,23 @@ public class SelfopTaskService { } if(testBusiness(task)) { require(task.getBusinessRootTaskId()==null&&task.getBusinessBudgetJson()==null,"BUSINESS_SESSION_IDENTITY_INVALID"); - if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { - require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + if(continuousBusiness(config)&&continuousBusinessRoot(task)){ + if("WAREHOUSE_SYNC".equals(task.getTaskRole())){ + var scope=scopes.selectById(task.getScopeId()); + require(scope!=null&&"INVENTORY".equals(scope.getDataFamily())&&Objects.equals(scope.getSourceAccount(),task.getSourceAccount()) + &&Objects.equals(scope.getWarehouseCode(),task.getWarehouseCode())&&Objects.equals(scope.getScopeHash(),task.getScopeHash()) + &&Objects.equals(scope.getScopeJson(),task.getQueryScopeJson()),"SELFOP_INVENTORY_SCOPE_INVALID"); + } + task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now)); + }else if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { + require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); task.setBusinessRootTaskId(task.getId()); task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds())); }else { var parent=businessParent(task); - require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); if("MANUAL_RETRY".equals(task.getTaskType()))require("SUCCEEDED".equals(task.getStatus())&&task.getRequestKey().startsWith("retry:")&&task.getUpstreamTaskId()!=null,"SELFOP_TEST_MANUAL_TASK_REQUIRED"); - require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); businessGuard(parent,!"WAREHOUSE_SYNC".equals(task.getTaskRole()));task.setBusinessRootTaskId(parent.getBusinessRootTaskId()); } } @@ -136,7 +144,7 @@ public class SelfopTaskService { @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) { lockBusinessAccount(taskId);leases.verify(lease); - if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));} + if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));} var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE"); checkTaskLease(task,lease); requireExecution(config,task.getExecutionPurpose()); @@ -190,6 +198,7 @@ public class SelfopTaskService { run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); // A yielded execution is not a failed request; finite failures are counted separately. long failures=runs.selectCount(new QueryWrapper().eq("task_id",taskId).eq("status","FAILED")); + if(task.getBusinessRootTaskId()!=null&&continuousBusinessRoot(tasks.selectById(task.getBusinessRootTaskId())))retryable=false; task.setStatus(retryable&&failures().eq("id",taskId).set("routing_manifest_json",null)); } - task.setNextRetryAtUtc(now.plusSeconds(Math.min(3600,5L< due(int limit){return locks.due(utcNow(),limit,config.getCollectionMode());} + public List due(int limit){return continuousBusiness(config)?locks.dueContinuous(utcNow(),limit,config.getSourceAccount()):locks.due(utcNow(),limit,config.getCollectionMode());} @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) { lockBusinessAccount(taskId);leases.verify(lease);guardBeforeTaskLock(taskId,false);var task=locks.lockTask(taskId);var run=locks.lockRun(runId); @@ -243,7 +254,7 @@ public class SelfopTaskService { } @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) { - lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run); + lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run); SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("YIELDED");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(until);task.setUpdatedAt(utcNow());tasks.updateById(task); } @@ -252,7 +263,7 @@ public class SelfopTaskService { @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public CollectionCheckpoint checkpoint(SelfopSyncTask observedTask,SelfopSyncRun observedRun,SelfopLeaseService.Lease lease) { long taskId=observedTask.getId();lockBusinessAccount(taskId);leases.verify(lease); - if("test".equals(config.getEnvironment())) { + if("test".equals(config.getEnvironment())||continuousBusiness(config)) { var preview=tasks.selectById(taskId);require(preview!=null,"SELFOP_TASK_MISSING");businessGuard(preview,false,false); } var task=locks.lockTask(taskId);var run=locks.lockRun(observedRun.getId());checkRunLease(task,run,lease); @@ -266,21 +277,21 @@ public class SelfopTaskService { BusinessSessionStoppedException(String reason){super("本次测试采集会话已结束或预算无效,请核对记录后新建有界任务");this.reason=reason;} public String reason(){return reason;} } - private boolean testBusiness(SelfopSyncTask task){return task!=null&&"test".equals(config.getEnvironment())&&"BUSINESS".equals(task.getExecutionPurpose());} + private boolean testBusiness(SelfopSyncTask task){return task!=null&&("test".equals(config.getEnvironment())||continuousBusiness(config))&&"BUSINESS".equals(task.getExecutionPurpose());} private SelfopSyncTask businessParent(SelfopSyncTask task){ Long id=task.getUpstreamTaskId(); if(id==null&&task.getQueryScopeJson()!=null){var query=read(task.getQueryScopeJson(),Map.class);if(query.get("parentTaskId") instanceof Number n)id=n.longValue();} return id==null?null:tasks.selectById(id); } private void lockBusinessAccount(long id){ - if(!"test".equals(config.getEnvironment()))return; + if(!"test".equals(config.getEnvironment())&&!continuousBusiness(config))return; var task=tasks.selectById(id);if(!testBusiness(task))return; require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED"); var account=accounts.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount())); require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId()); } private void guardBeforeTaskLock(long id,boolean checkDeadline){ - if(!"test".equals(config.getEnvironment()))return; + if(!"test".equals(config.getEnvironment())&&!continuousBusiness(config))return; var task=tasks.selectById(id);businessGuard(task,checkDeadline); } private boolean localBusinessWork(SelfopSyncTask task){ @@ -301,22 +312,22 @@ public class SelfopTaskService { private void businessGuard(SelfopSyncTask task,boolean checkDeadline){businessGuard(task,checkDeadline,true);} private void businessGuard(SelfopSyncTask task,boolean checkDeadline,boolean terminate){ if(!testBusiness(task))return; - require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED"); var ancestor=task; - while(!ancestor.getRequestKey().startsWith("manual:"))ancestor=businessParent(ancestor); + while(!ancestor.getRequestKey().startsWith("manual:")&&!continuousBusinessRoot(ancestor))ancestor=businessParent(ancestor); require(Objects.equals(ancestor.getId(),task.getBusinessRootTaskId()),"BUSINESS_SESSION_ROOT_INVALID"); // Serialize with egress in account -> root -> task order, before taking any task row lock. var account=accounts.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount())); require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId()); var root=locks.lockTask(task.getBusinessRootTaskId()); require(root!=null&&Objects.equals(root.getId(),root.getBusinessRootTaskId())&&Objects.equals(root.getSourceAccount(),task.getSourceAccount()) - &&root.getRequestKey()!=null&&root.getRequestKey().startsWith("manual:")&&controlledBusinessTask(root,tasks::selectById),"BUSINESS_SESSION_ROOT_INVALID"); + &&root.getRequestKey()!=null&&(root.getRequestKey().startsWith("manual:")||continuousBusinessRoot(root))&&controlledBusinessTask(root,tasks::selectById,continuousBusiness(config)),"BUSINESS_SESSION_ROOT_INVALID"); require(root.getBusinessBudgetJson()!=null,"BUSINESS_SESSION_BUDGET_INVALID"); var budget=read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);budget.validate(); - // HTTP exhaustion is enforced at egress, allowing the final permitted response to finish local work. + require(!budget.continuous||(continuousBusiness(config)&&continuousBusinessRoot(root)),"SELFOP_CONTINUOUS_COLLECTION_DISABLED"); String reason=budget.stopReason; - if(reason==null&&checkDeadline&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE"; + if(reason==null&&checkDeadline&&!budget.continuous&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE"; if(reason==null&&"DEAD".equals(root.getStatus()))reason="BUSINESS_SESSION_STOPPED"; if(reason==null)return; if(!terminate)throw new BusinessSessionStoppedException(reason); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index b7d66926e..98981b1d6 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -48,6 +48,7 @@ lastleg: baiyue: self-operated-analytics: collection-mode: ${SELFOP_COLLECTION_MODE:OFF} + continuous-business-collection-enabled: ${SELFOP_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false} environment: ${DEPLOY_ENV:test} daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0} business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index a585486bd..37711e475 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -732,6 +732,8 @@ components: Task: type: object properties: + createdAt: {type: string, format: date-time, nullable: true, description: 当前任务创建时间,重试和阶段切换不重置} + elapsedSeconds: {type: string, nullable: true, description: 自创建起的累计秒数,含排队和重试;活动任务截止本次读取,终态任务截止任务最后更新时间;不是单次执行耗时} taskNo: {type: string, nullable: true} taskType: {type: string, nullable: true} dataFamily: {type: string, nullable: true} @@ -807,7 +809,7 @@ components: executionEnabled: {type: boolean, nullable: true} publicationEnabled: {type: boolean, nullable: true} disabledReason: {type: string, nullable: true} - nextScheduledAt: {type: string, format: date-time, nullable: true} + nextScheduledAt: {type: string, format: date-time, nullable: true, description: 自动采集下一计划时刻;显式连续BUSINESS模式下销量为下个北京时间00点05分,库存沿仓库计划;执行被禁用时为空,不代表当前任务预计完成时间} observedAt: {type: string, format: date-time, nullable: true} initialFrom: {type: string, format: date-time, nullable: true} initialUntil: {type: string, format: date-time, nullable: true} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java index bf712d1fd..f568f0ef5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java @@ -24,7 +24,7 @@ class SelfopStageEstimateTest { @Test void sufficientRealAdvancesEstimateCurrentStageAndIncludeIdleTime(){ var p=ready();var e=estimate(p,60);assertEquals("AVAILABLE",e.status());assertEquals(60L,e.completedDelta()); assertEquals(0,new BigDecimal("1").compareTo(e.ratePerSecond()));assertEquals(939L,e.remainingSeconds()); - assertEquals(start,e.sampleFrom());assertEquals(start.plusSeconds(60),e.sampleUntil());assertEquals(start.plusSeconds(70),e.validUntil()); + assertEquals(start,e.sampleFrom());assertEquals(start.plusSeconds(60),e.sampleUntil());assertEquals(start.plusSeconds(90),e.validUntil()); var idle=estimate(p,90);assertEquals("AVAILABLE",idle.status());assertEquals(new BigDecimal("0.666666"),idle.ratePerSecond()); assertEquals(1409L,idle.remainingSeconds());assertEquals("NO_RECENT_PROGRESS",estimate(p,120).unavailableReason()); } @@ -33,6 +33,26 @@ class SelfopStageEstimateTest { assertEquals("SAMPLING",estimate(p,59).unavailableReason());assertEquals("SAMPLING",estimate(p,60).unavailableReason()); commit(p,60,4,true);assertEquals("AVAILABLE",estimate(p,60).status()); } + @Test void unchangedBookkeepingDoesNotRestartSamplingOrInventAnAdvance(){ + var p=ready();commit(p,65,61,false); + var e=estimate(p,65); + assertEquals("AVAILABLE",e.status());assertEquals(start,e.sampleFrom()); + assertEquals(60L,e.completedDelta());assertEquals(new BigDecimal("0.923076"),e.ratePerSecond()); + commit(p,119,61,false); + assertEquals("NO_RECENT_PROGRESS",estimate(p,120).unavailableReason()); + } + @Test void rollingWindowKeepsAValidityMarginAcrossTenSecondPollingBoundaries(){ + var p=progress();p.put("total",100000L); + for(int second=0;second<=600;second++){ + commit(p,second,second,true); + if(second>=80&&second%10==0){ + var e=estimate(p,second); + assertEquals("AVAILABLE",e.status(),"second="+second); + assertEquals(start.plusSeconds(second+30),e.validUntil()); + assertFalse(e.sampleFrom().isBefore(e.validUntil().minusSeconds(SelfopStageEstimate.WINDOW_SECONDS))); + } + } + } @Test void reuseBeforeInsideAndAfterWindowRestartsBaselineInsteadOfInflatingRate(){ var p=progress();commit(p,0,500,false);commit(p,20,501,true);commit(p,40,502,true);commit(p,60,503,true); assertEquals(3L,estimate(p,60).completedDelta()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java index 538c72b02..6fd4d095d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java @@ -160,7 +160,7 @@ class OutboundCandidateBuildMySqlAcceptanceTest { LocalDateTime now=utcNow().minusSeconds(1);String scopeJson=json(Map.of("quantityUnit","PCS","businessTimezone","Asia/Shanghai","sourceTimezone","Asia/Shanghai","fixtureOrigin","SYNTHETIC_BUILD_ACCEPTANCE"));String hash=hash(scopeJson); transactions.executeWithoutResult(s->{ insert("ll_selfop_sync_scope",fields("id",scopeId,"created_at",now,"updated_at",now,"source_account",account,"warehouse_code",warehouse,"data_family","OUTBOUND","scope_json",scopeJson,"scope_hash",hash,"business_timezone","Asia/Shanghai","source_timezone","Asia/Shanghai","schedule_cron","0 0 0 1 1 *","stale_after_seconds",93600,"review_request_budget",1,"review_time_budget_seconds",1,"review_target_days",7,"fact_revision",1,"published_fact_revision",0,"fence_token",1,"lease_owner",owner,"lease_until_utc",utcNow().plusHours(1))); - for(long id:List.of(originTask,taskId))insert("ll_selfop_sync_task",fields("id",id,"created_at",now,"updated_at",now,"task_no","SYNTH-BUILD-"+id,"execution_purpose","BUSINESS","source_account",account,"warehouse_code",warehouse,"task_role","WAREHOUSE_SYNC","scope_id",scopeId,"request_key","build-"+id,"request_hash",hash,"task_type","REBUILD","query_scope_json",scopeJson,"scope_hash",hash,"status",id==taskId?"RUNNING":"SUCCEEDED","apply_state","APPLIED","attempt_count",1,"query_from_utc",utc(from),"query_until_utc",utc(until),"routing_manifest_json","{\"directoryComplete\":true}")); + for(long id:List.of(originTask,taskId))insert("ll_selfop_sync_task",fields("id",id,"created_at",now,"updated_at",now,"task_no","SYNTH-BUILD-"+id,"execution_purpose","BUSINESS","source_account",account,"warehouse_code",warehouse,"task_role","WAREHOUSE_SYNC","scope_id",scopeId,"request_key","build-"+id,"request_hash",hash,"task_type","REBUILD","query_scope_json",scopeJson,"scope_hash",hash,"status",id==taskId?"RUNNING":"SUCCEEDED","apply_state","APPLIED","apply_completed_at_utc",now,"attempt_count",1,"query_from_utc",utc(from),"query_until_utc",utc(until),"routing_manifest_json","{\"directoryComplete\":true}")); for(long id:List.of(originRun,runId))insert("ll_selfop_sync_run",fields("id",id,"created_at",now,"updated_at",now,"task_id",id==runId?taskId:originTask,"execution_purpose","BUSINESS","scope_id",scopeId,"source_account",account,"warehouse_code",warehouse,"run_role","WAREHOUSE_SYNC","attempt_no",1,"scope_hash",hash,"scope_snapshot_json",scopeJson,"fence_token",1,"status","FACTS_APPLIED","last_phase","BUILD","coverage_status","UNKNOWN")); }); for(int offset=0;offset"UNKNOWN".equals(p.dataStatus())&&p.observedQuantity()==null)); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture());assertFalse(cap.getValue().complete30()); + } + private static Run frozenRun(LocalDateTime from,LocalDateTime until, + com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval... intervals){ + Run original=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"); + var frozen=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun(); + frozen.setId(11L);frozen.setScopeId(1L);frozen.setScopeHash(SH);frozen.setScopeSnapshotJson(original.scopeSnapshotJson()); + com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.freeze(frozen,List.of(intervals)); + var json=new ObjectMapper().findAndRegisterModules();var fields=json.convertValue(original,Map.class); + fields.put("scopeSnapshotJson",frozen.getScopeSnapshotJson());fields.put("coveredFromUtc",from);fields.put("coveredUntilUtc",until); + return json.convertValue(fields,Run.class); + } + @Test void selectedRunFrozenIslandsDriveDailyAndWindowEligibility(){ + Run original=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"); + var frozen=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun();frozen.setId(11L);frozen.setScopeId(1L);frozen.setScopeHash(SH);frozen.setScopeSnapshotJson(original.scopeSnapshotJson()); + com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.freeze(frozen,List.of( + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(5)),time(D.minusDays(4))), + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D),time(D.plusDays(1))))); + var json=new ObjectMapper().findAndRegisterModules();var fields=json.convertValue(original,Map.class);fields.put("scopeSnapshotJson",frozen.getScopeSnapshotJson()); + fields.put("coveredFromUtc",time(D.minusDays(5)));fields.put("coveredUntilUtc",time(D.minusDays(4))); + when(mapper.publishedRun(11)).thenReturn(json.convertValue(fields,Run.class));service=at(NOW.plusSeconds(86400)); + var response=service.dailySales(chart("CUSTOM",D.minusDays(5),D,"C","SKU")); + assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN","UNKNOWN","COMPLETE"),response.points().stream().map(AnalyticsResponses.Point::dataStatus).toList()); + assertNull(response.summary().outboundQuantity());assertEquals(BigDecimal.ZERO,response.points().get(5).outboundQuantity()); + var snapshot=service.context(chart("CUSTOM",D,D,null,null),true,false,false,null); + assertTrue(service.request(snapshot,"SUMMARY").completeWindow());assertFalse(service.request(snapshot,"SUMMARY").complete30()); + } private static final String SH="a".repeat(64), IH="b".repeat(64); private static final LocalDate D=LocalDate.of(2026,10,12); private static final Instant NOW=Instant.parse("2026-10-12T04:00:00Z"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java index a6d46fcb5..f7b696925 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java @@ -16,7 +16,7 @@ class AnalyticsReadMapperTest { private static AnalyticsSqlRequest request(String kind){ LocalDate d=LocalDate.of(2026,10,12); return new AnalyticsSqlRequest("demo","W","OWNER","Exact-SKU",11,22,d.minusDays(2),d.plusDays(1),d,d.minusDays(7), - d.minusDays(30),d,LocalDateTime.of(2026,9,11,16,0),LocalDateTime.of(2026,10,12,3,0),true,true,true,true,true,20,20,kind); + d.minusDays(30),d,LocalDateTime.of(2026,9,11,16,0),LocalDateTime.of(2026,10,12,3,0),true,true,true,true,true,20,20,kind,java.util.List.of()); } private static BoundSql bound(Configuration c,String method,Map parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);} private static void immutableOnly(String sql){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java index 4bdb3b348..be6937aa7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java @@ -17,6 +17,16 @@ import static org.junit.jupiter.api.Assertions.*; * original BoundSql mappings. Candidate counts and separate binding contracts remain prepared. * No external database, credentials or HTTP; MySQL plans/performance remain separate gates. */ public class AnalyticsSqlSemanticTest { + @Test void frozenDisconnectedDaysPreserveZeroDayAndUnknownGapInSummarySql() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","C","SKU",D,"12"); + var coverage=List.of(new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(utc(D.minusDays(5),0),utc(D.minusDays(4),0)), + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(utc(D,0),utc(D.plusDays(1),0))); + var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(5),D.plusDays(1),D.plusDays(1),D.minusDays(30),D.minusDays(5),D.minusDays(4), + utc(D.minusDays(5),0),utc(D.minusDays(4),0),true,true,false,false,false,0,20,"SUMMARY",coverage); + var result=page(p).get(0);assertEquals(2,result.completeDayCount());assertEquals(0,result.partialDayCount());assertEquals(4,result.unknownDayCount()); + assertEquals(0,new BigDecimal("12").compareTo(result.observedQuantity())); + } private Connection schema; private SqlSession session; private AnalyticsReadMapper mapper; @@ -68,7 +78,7 @@ public class AnalyticsSqlSemanticTest { daily(11,"W","C","SOLD",from.minusDays(1),"999");daily(11,"W","C","SOLD",until,"999"); daily(33,"W","C","SOLD",from,"999");daily(11,"OTHER","C","SOLD",from,"999"); var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,from,until,D,D.minusDays(30),from,D, - utc(from,0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY"); + utc(from,0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY",java.util.List.of()); assertEquals(3,mapper.count(p));var rows=page(p);assertEquals(3,rows.size()); var sold=rows.get(0);assertEquals("SOLD",sold.skuCode());assertDecimal("5",sold.observedQuantity()); assertEquals(2L,sold.skuOrderCount());assertEquals(365,sold.completeDayCount()); @@ -147,15 +157,15 @@ public class AnalyticsSqlSemanticTest { new LocalDateTime[]{utc(D.plusDays(1),0),utc(D.plusDays(2),0)}, new LocalDateTime[]{null,utc(D,8)})){ var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null, - span[0],span[1],true,false,false,false,true,0,1,"SUMMARY"); + span[0],span[1],true,false,false,false,true,0,1,"SUMMARY",java.util.List.of()); var first=page(p).get(0);assertEquals("A-UNKNOWN",first.skuCode());assertNull(first.observedQuantity()); var last=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null, - span[0],span[1],true,false,false,false,true,1,1,"SUMMARY"); + span[0],span[1],true,false,false,false,true,1,1,"SUMMARY",java.util.List.of()); assertNull(page(last).get(0).observedQuantity()); } daily(11,"W","C","Z-EVIDENCE",D,"99"); var unavailable=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null, - utc(D,0),utc(D,8),false,false,false,false,true,0,1,"SUMMARY"); + utc(D,0),utc(D,8),false,false,false,false,true,0,1,"SUMMARY",java.util.List.of()); var first=page(unavailable).get(0);assertEquals("A-UNKNOWN",first.skuCode());assertNull(first.observedQuantity()); } @Test void evidenceAfterCoveredTimeOnSameDayDoesNotBecomeObservedZero() throws Exception { @@ -307,7 +317,7 @@ public class AnalyticsSqlSemanticTest { daily(11,"W","A","SAME",D.minusDays(1),"2");daily(11,"W","A","SAME",D,"5"); daily(11,"W","B","SAME",D.minusDays(2),"9"); var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(365),D.plusDays(1),D,D.minusDays(30), - D.minusDays(365),D,utc(D.minusDays(365),0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY"); + D.minusDays(365),D,utc(D.minusDays(365),0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY",java.util.List.of()); assertEquals(4,mapper.count(p));var rows=page(p);assertEquals(4,rows.size()); for(var row:rows)if("SAME".equals(row.skuCode())){ assertDecimal("A".equals(row.customerCode())?"8":"9",row.observedQuantity()); @@ -432,11 +442,11 @@ public class AnalyticsSqlSemanticTest { profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");stock("C","SKU",0,"10");daily(11,"W","C","SKU",D.minusDays(1),"3"); var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null,null,null); var stale=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D, - utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY"); + utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY",java.util.List.of()); var row=inventoryRows(stale,options).get(0);assertNull(row.physicalQuantity());assertNull(row.availableQuantity());assertDecimal("3",row.ownerOutbound());assertNull(row.coverageDays()); - var noSales=new AnalyticsSqlRequest("demo","W",null,null,0,22,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,true,0,20,"INVENTORY"); + var noSales=new AnalyticsSqlRequest("demo","W",null,null,0,22,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,true,0,20,"INVENTORY",java.util.List.of()); row=inventoryRows(noSales,options).get(0);assertDecimal("10",row.availableQuantity());assertNull(row.ownerOutbound());assertNull(row.observedOutbound());assertNull(row.outboundQuantity30d());assertNull(row.lastOutbound()); - var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY"); + var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY",java.util.List.of()); assertTrue(inventoryRows(empty,options).isEmpty());assertEquals(0L,((Number)literalQuery("inventoryCount",empty,options).get(0)).longValue()); } @Test void tenDayInventoryUsesItsOwnCompleteCoverageAndUnroundedThresholdWithoutLeakingScope()throws Exception { @@ -449,7 +459,7 @@ public class AnalyticsSqlSemanticTest { daily(99,"W","C","TEN",D.minusDays(1),"700");daily(11,"OTHER","C","TEN",D.minusDays(1),"600"); daily(11,"W","OTHER","TEN",D.minusDays(1),"500"); var p=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(10),D, - utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY"); + utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY",java.util.List.of()); var o=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1)); var rows=inventoryRows(p,o);var ten=rows.stream().filter(r->r.skuCode().equals("TEN")).findFirst().orElseThrow(); assertDecimal("2",ten.outboundQuantity30d());assertDecimal("30.04",ten.coverageDays());assertEquals(1,ten.aboveThreshold()); @@ -464,7 +474,7 @@ public class AnalyticsSqlSemanticTest { assertEquals(1L,((Number)literalQuery("inventoryCount",p,filtered).get(0)).longValue()); assertEquals(List.of("TEN"),inventoryRows(p,filtered).stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); var partial=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(9),D, - utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY"); + utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY",java.util.List.of()); assertTrue(inventoryRows(partial,o).stream().allMatch(r->r.outboundQuantity30d()==null&&r.coverageDays()==null)); } @SuppressWarnings("unchecked") private List inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ @@ -536,7 +546,7 @@ public class AnalyticsSqlSemanticTest { } private AnalyticsSqlRequest request(String kind,LocalDate from,LocalDate until,String owner,String sku,LocalDateTime coveredUntil,long offset,int size) { return new AnalyticsSqlRequest("demo","W",owner,sku,11,22,from,until,D,D.minusDays(30),D.minusDays(30),D, - utc(D.minusDays(30),0),coveredUntil,true,true,true,true,true,offset,size,kind); + utc(D.minusDays(30),0),coveredUntil,true,true,true,true,true,offset,size,kind,java.util.List.of()); } private static LocalDateTime utc(LocalDate d,int hour){return d.atTime(hour,0).atZone(ZoneId.of("Asia/Shanghai")).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();} private void profile(long run,String owner,String sku,String evidence,LocalDateTime at,String unit)throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java index 9480f74a8..6ffad22d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java @@ -29,6 +29,15 @@ class CollectionQueryServiceTest { return new Task(id,"LXCOL-"+id,"primary",account?null:"A","OUTBOUND",account?"ACCOUNT_DISCOVERY":"WAREHOUSE_SYNC","BACKFILL","BUSINESS", "hash",status,"NOT_STARTED",1,now.minusDays(1),now,null,upstream,null,null,progress,now,null,null,false,created,created); } + @Test void taskElapsedIncludesWaitingAndRetriesAndStopsAtTerminalTaskUpdate() { + for(String status:List.of("RUNNING","RETRY_WAIT","WAITING_SOURCE","SUCCEEDED")) { + var task=activity(7,status,null,false,now.minusMinutes(10),null); + when(mapper.task("primary","A",task.taskNo())).thenReturn(task); + var result=service.taskDetail(task.taskNo(),q(null,null,null)).task(); + assertEquals(now.minusMinutes(10).toInstant(ZoneOffset.UTC),result.createdAt()); + assertEquals("SUCCEEDED".equals(status)?0L:600L,result.elapsedSeconds()); + } + } @Test void currentActivityWinsOverNewerHistoryAndIsBoundedToEight() { var candidates=new ArrayList(); candidates.add(activity(99,"SUCCEEDED",null,false,now,null)); @@ -132,6 +141,37 @@ class CollectionQueryServiceTest { config.setEnvironment("development"); for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("BUSINESS_ENVIRONMENT_REJECTED",row.disabledReason());} } + @Test void continuousTestBusinessShowsDailyPlanDespiteExpiredGrantButKeepsSafetyGates() { + enableBusiness("test");config.setContinuousBusinessCollectionEnabled(true); + config.setExclusiveGrantExpiresAtUtc(now.minusDays(1)); + var rows=service.overview(q(null,null,null)).warehouses(); + var outbound=rows.stream().filter(r->"OUTBOUND".equals(r.dataFamily())).findFirst().orElseThrow(); + assertTrue(outbound.executionEnabled()); + assertEquals(Instant.parse("2026-10-12T16:05:00Z"),outbound.nextScheduledAt()); + var atSlot=new CollectionQueryService(mapper,config,new ObjectMapper(),Clock.fixed(Instant.parse("2026-10-12T16:05:00Z"),ZoneOffset.UTC)); + assertEquals(Instant.parse("2026-10-13T16:05:00Z"),atSlot.overview(q(null,null,null)).warehouses().stream() + .filter(r->"OUTBOUND".equals(r.dataFamily())).findFirst().orElseThrow().nextScheduledAt()); + config.setPriorEgressClosed(false); + for(var row:service.overview(q(null,null,null)).warehouses()) { + assertFalse(row.executionEnabled());assertNull(row.nextScheduledAt()); + assertEquals("EXCLUSIVE_GRANT_NOT_VALID",row.disabledReason()); + } + config.setPriorEgressClosed(true);config.setContinuousBusinessCollectionEnabled(false); + assertFalse(service.overview(q(null,null,null)).warehouses().get(0).executionEnabled()); + } + @Test void continuousBudgetRequiresEnabledModeAndNoDeadlineWhileBoundedBudgetsStillExpire() { + enableBusiness("test");config.setContinuousBusinessCollectionEnabled(true); + String continuous="{\"continuous\":true,\"startedAtUtc\":\""+now.minusDays(2)+"\",\"maxHttp\":0,\"http\":10,\"confirmedHttp\":10,\"deadlineAtUtc\":null,\"stopReason\":null}"; + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous)); + assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions()); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous.replace("\"deadlineAtUtc\":null","\"deadlineAtUtc\":\""+now.plusDays(1)+"\""))); + assertEquals("BUSINESS_SESSION_BUDGET_MISSING",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.minusMinutes(1).toString(),null,10))); + assertEquals("BUSINESS_SESSION_DEADLINE",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + config.setContinuousBusinessCollectionEnabled(false); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous)); + assertEquals("CONTINUOUS_COLLECTION_DISABLED",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + } Scope scope() { return new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600, now.minusMonths(2),now,null,null,0,null,null,null,7,null,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java index 9a15b4252..585c2628d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java @@ -9,43 +9,52 @@ import java.util.*; import static org.junit.jupiter.api.Assertions.*; class DashboardPolicyTest { + @Test void frozenIslandsProveLatestEmptyDayWithoutFillingInterveningDates(){ + var first=DAY.minusDays(6).atStartOfDay().minusHours(8);var last=DAY.minusDays(1).atStartOfDay().minusHours(8); + var coverage=List.of(new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(first,first.plusDays(1)), + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(last,last.plusDays(1))); + var c=context(first.toInstant(ZoneOffset.UTC),first.plusDays(1).toInstant(ZoneOffset.UTC),true); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(6),DAY,List.of(),coverage); + assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN","UNKNOWN","COMPLETE"),points.stream().map(p->p.dataStatus()).toList()); + assertEquals(BigDecimal.ZERO,points.get(5).outboundQuantity());assertNull(SalesBucketPolicy.aggregate(points).outboundQuantity()); + } static final LocalDate DAY=LocalDate.of(2026,10,13); static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z"); static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,DAY.minusDays(14),DAY.plusDays(1),null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());} @Test void warehouseObservedTotalPreservesOutsideWatermarkAndUnknownGap(){ var c=context(null,null,true);var rows=List.of(new DashboardReadMapper.Day(DAY.minusDays(3),new BigDecimal("2"),0L),new DashboardReadMapper.Day(DAY.minusDays(1),new BigDecimal("5"),0L),new DashboardReadMapper.Day(DAY,BigDecimal.ONE,0L)); - var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows);var summary=SalesBucketPolicy.aggregate(points); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows,java.util.List.of());var summary=SalesBucketPolicy.aggregate(points); assertEquals(new BigDecimal("8"),summary.observedQuantity());assertNull(summary.outboundQuantity());assertNull(points.get(1).observedQuantity());assertEquals("PARTIAL",points.get(3).dataStatus()); } @Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){ var c=context(DAY.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),NOW,true); - var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of()); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of(),java.util.List.of()); assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(BigDecimal.ZERO,points.get(0).outboundQuantity()); assertEquals("PARTIAL",points.get(1).dataStatus());assertEquals(BigDecimal.ZERO,points.get(1).observedQuantity());assertNull(points.get(1).outboundQuantity()); - var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of()));assertNull(unknown.observedQuantity()); + var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of(),java.util.List.of()));assertNull(unknown.observedQuantity()); } @Test void staleCollectionDoesNotDowngradePublishedHistoricalDayOrInventOtherFourteenDays(){ LocalDate published=DAY.minusDays(3);var c=publishedDayContext("STALE",false); for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))) { var rows=amount.signum()==0?List.of():List.of(new DashboardReadMapper.Day(published,amount,0L)); - var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(14),DAY.plusDays(1),rows); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(14),DAY.plusDays(1),rows,java.util.List.of()); var point=points.stream().filter(p->p.date().equals(published)).findFirst().orElseThrow(); assertEquals("COMPLETE",point.dataStatus());assertEquals(amount,point.outboundQuantity()); assertFalse(point.reasonCodes().contains("SALES_DAY_INCOMPLETE"));assertFalse(point.reasonCodes().contains("SALES_STALE")); var summary=SalesBucketPolicy.aggregate(points); assertEquals(1,summary.completeDays());assertEquals(14,summary.unknownDays());assertEquals(0,summary.partialDays()); assertEquals("PARTIAL",summary.dataStatus());assertNull(summary.outboundQuantity());assertEquals(amount,summary.observedQuantity()); - var single=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(c,true,published,published.plusDays(1),rows)); + var single=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(c,true,published,published.plusDays(1),rows,java.util.List.of())); assertEquals("COMPLETE",single.dataStatus());assertEquals(amount,single.outboundQuantity()); assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE")); } } @Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){ LocalDate day=DAY.minusDays(3);var rows=List.of(new DashboardReadMapper.Day(day,new BigDecimal("2251"),0L)); - assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows).get(0).dataStatus()); - var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L))).get(0); + assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows,java.util.List.of()).get(0).dataStatus()); + var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L)),java.util.List.of()).get(0); assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());assertTrue(invalid.reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID")); - var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows).get(0); + var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows,java.util.List.of()).get(0); assertEquals("PARTIAL",rebuilding.dataStatus());assertNull(rebuilding.outboundQuantity()); } private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java index d42ba3586..23a3c6784 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java @@ -20,7 +20,7 @@ class DashboardQueryServiceTest { var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366); var snapshot=new AnalyticsQueryService.Snapshot(q,salesRun(true),null,true,true,day.minusDays(30),day,range,context); when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot); - var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD"); + var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD",java.util.List.of()); when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p); when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L))); var reportRow=new HashMap(Map.of("total_skus",200L,"hot_eligible",200L,"hot_unknown",0L)); @@ -54,7 +54,7 @@ class DashboardQueryServiceTest { var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context); when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot); when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day, - day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD")); + day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD",java.util.List.of())); var result=service.dashboard(q); if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());} else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index f152bf4c0..dff6165bf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -44,12 +44,12 @@ class DashboardSqlSemanticTest { daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS"); var b=request(false,true); var p=new AnalyticsSqlRequest(b.account(),b.warehouse(),null,null,b.salesRun(),b.inventoryRun(),D.minusDays(2),D.plusDays(1),D,D.minusDays(2), - b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD"); + b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD",java.util.List.of()); var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options())); assertEquals(1,rows.size());assertEquals("RECENT",((Map)rows.get(0)).get("SKU_CODE")); assertEquals(0,new BigDecimal("10").compareTo((BigDecimal)((Map)rows.get(0)).get("MAIN_VALUE"))); var top=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),D,p.from(), - p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT"); + p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT",java.util.List.of()); var topRows=query(AnalyticsReadMapper.class,"page",Map.of("p",top)); assertEquals(1,topRows.size());var item=(AnalyticsReadRows.Aggregate)topRows.get(0); assertEquals("RECENT",item.skuCode());assertEquals(0,new BigDecimal("10").compareTo(item.windowQuantity()));assertNull(item.sales30Quantity()); @@ -162,7 +162,7 @@ class DashboardSqlSemanticTest { } daily("C","SKU-199",D,"999","BOX"); assertEquals(200,count(stats(),"hot_eligible"));var top=ranks().stream().filter(r->r.rankCode().equals("HOT")).sorted(Comparator.comparing(DashboardReadMapper.Ranked::mainValue).reversed()).toList();assertEquals(10,top.size()); - var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT"); + var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT",java.util.List.of()); List page=query(AnalyticsReadMapper.class,"page",Map.of("p",hot));assertEquals(10,page.size()); for(int i=0;i<10;i++){var item=(AnalyticsReadRows.Aggregate)page.get(i);assertEquals(top.get(i).skuCode(),item.skuCode());assertEquals(0,top.get(i).mainValue().compareTo(item.windowQuantity()));} } @@ -199,13 +199,13 @@ class DashboardSqlSemanticTest { .stream().map(DashboardReadMapper.Day.class::cast).toList(); assertEquals(originalDailyOracle(D.minusDays(3),D.plusDays(1)),rows,"Published summaries must retain the former SKU aggregation's valid subtotal and invalid count"); var covered=DashboardPolicyTest.context(D.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DashboardPolicyTest.NOW,true); - var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows); + var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows,java.util.List.of()); assertEquals("PARTIAL",points.get(0).dataStatus());assertEquals(0,new BigDecimal("7").compareTo(points.get(0).observedQuantity()));assertNull(points.get(0).outboundQuantity()); assertTrue(points.get(0).reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID")); assertEquals("UNKNOWN",points.get(1).dataStatus());assertNull(points.get(1).observedQuantity()); assertEquals("COMPLETE",points.get(2).dataStatus());assertEquals(BigDecimal.ZERO,points.get(2).outboundQuantity()); assertEquals("PARTIAL",points.get(3).dataStatus());assertEquals(0,new BigDecimal("4").compareTo(points.get(3).observedQuantity()));assertNull(points.get(3).outboundQuantity()); - var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows); + var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows,java.util.List.of()); assertTrue(unproved.stream().allMatch(point->point.outboundQuantity()==null)); assertNull(unproved.get(2).observedQuantity());assertEquals("UNKNOWN",unproved.get(2).dataStatus()); } @@ -238,9 +238,9 @@ class DashboardSqlSemanticTest { private void stock(String sku,String physical,String available)throws Exception{stock("C",sku,physical,available);} private void stock(String owner,String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){ p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}} - private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");} - private AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY");} - private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind());} + private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD",java.util.List.of());} + private AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY",java.util.List.of());} + private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind(),java.util.List.of());} private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);} @SuppressWarnings("unchecked") private List> report(){ return ((List>)(List)query(DashboardReadMapper.class,"report",Map.of("p",request(),"o",options()))).stream().map(row->{ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java index 5ebd87176..576a0ca4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java @@ -6,6 +6,15 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class SelfopBusinessBudgetTest { + @Test void continuousSessionHasNoDeadlineButKeepsStopAndAccountingGuards(){ + var start=LocalDateTime.of(2026,10,13,0,0); + var budget=SelfopSupport.read(SelfopBusinessBudget.freezeContinuous(start),SelfopBusinessBudget.class); + assertTrue(budget.continuous);assertNull(budget.deadlineAtUtc); + budget.http=100000;budget.confirmedHttp=99999; + assertNull(budget.limit(start.plusYears(1))); + budget.stopReason="EGRESS_RESULT_UNKNOWN";assertEquals(budget.stopReason,budget.limit(start)); + budget.deadlineAtUtc=start.plusDays(1);assertThrows(EgressRejectedException.class,budget::validate); + } private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0); @Test void serializedConsumptionAndDeadlineSurviveReload() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index 912feb18e..c8ccc609e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -44,6 +44,46 @@ class SelfopEgressServiceTest { assertThat(budget().reservedHttp).isEqualTo(1); } @Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();} + @Test void continuousBusinessGrantExpiryDoesNotClipShortPendingOrPermitAndSwitchKeepsCounters(){ + testBusiness(5,300);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); + cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setEgressLeaseSeconds(120); + var c=reserve(Operation.WAREHOUSES,1,null,null); + assertThat(budget().pending.expiresAtUtc).isEqualTo(now().plusSeconds(120)); + var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThat(restarted.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)).expiresAt()).isEqualTo(clock.instant().plusSeconds(30)); + restarted.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); + assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + } + @Test void continuousFlagNeverBypassesValidationWindowGrantIdentityOrHandoff(){ + cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + testBusiness(5,300); + for(String grant:Arrays.asList(null,"","other")){cfg.setExclusiveGrantId(grant);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");} + cfg.setExclusiveGrantId("grant");cfg.setPriorEgressClosed(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper); + } + @Test void continuousGrantDoesNotRenewFiniteManualRootOrRelaxFence(){ + testBusiness(5,10);cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); + account.setFenceToken(8L); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_FENCE_LOST"); + account.setFenceToken(7L);var c=reserve(Operation.WAREHOUSES,1,null,null); + assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc); + clock.advance(10); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_SESSION_DEADLINE"); + assertThat(budget().pending.consumed).isFalse(); + } + @Test void continuousBusinessAcrossMidnightRetainsUnknownPendingAndReviewAcrossRestart(){ + business();cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); + var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE");clock.advance(86400); + var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero();assertThat(budget().pending.abandoned).isTrue(); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(cfg.getCooldownSeconds()+1); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + } @Test void allFamiliesAndFailuresConsumeOneRootAndAccountAcrossServiceRestart(){ var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES); c=reserve(Operation.PRODUCT_INVENTORY,2,"A",null);success(c,Operation.PRODUCT_INVENTORY); @@ -150,6 +190,99 @@ class SelfopEgressServiceTest { private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");} private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),seconds));} private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);} + private void continuousBusinessRoot(){ + business();cfg.setEnvironment("test");cfg.setContinuousBusinessCollectionEnabled(true); + cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setBusinessMinRequestIntervalMs(1200); + task.setValidationRootTaskId(null);task.setValidationBudgetJson(null);task.setUpstreamTaskId(null); + task.setTaskType("BACKFILL");task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash("a".repeat(64)); + var from=LocalDate.of(2026,10,11).atStartOfDay();task.setQueryFromUtc(from.minusHours(8));task.setQueryUntilUtc(from.plusDays(1).minusHours(8)); + task.setQueryScopeJson(SelfopSupport.json(Map.of("startTime",from.toString(),"endTime",from.plusDays(1).toString()))); + task.setRequestKey("continuous:outbound:2026-10-11:"+task.getScopeHash().substring(0,12)); + task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now())); + assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue(); + } + @Test void continuousRootWithNoDeadlineReservesAuthorizesAcrossDayWithoutResettingAccumulated8820(){ + continuousBusinessRoot();cfg.setEgressLeaseSeconds(999);cfg.setDailyRequestBudget(1); + var inherited=new SelfopApiBudget();inherited.budgetDate=now().plusHours(8).toLocalDate();inherited.importedGrantId="grant"; + inherited.reservedHttp=8820;inherited.confirmedHttp=8820;inherited.minIntervalMs=1200;account.setApiBudgetJson(SelfopSupport.json(inherited)); + var b=businessBudget();b.http=8820;b.confirmedHttp=8820;task.setBusinessBudgetJson(SelfopSupport.json(b));var started=b.startedAtUtc; + var c=reserve(Operation.OUTBOUND_QUERY,100,null,null); + assertThat(budget().pending.expiresAtUtc).isEqualTo(now().plusSeconds(120)); + var restarted=new SelfopEgressService(mapper,cfg,clock); + var permit=restarted.authorize(new AuthorizationRequest(c,Operation.OUTBOUND_QUERY,PROVIDER)); + assertThat(permit.expiresAt()).isEqualTo(clock.instant().plusSeconds(30));assertThat(permit.allowedAttempts()).isEqualTo(1); + var completion=new CompletionRequest(c,Operation.OUTBOUND_QUERY,PROVIDER,1,null,200,200,false,null); + restarted.complete(completion);restarted.complete(completion); + clock.advance(86400);account.setLeaseUntilUtc(now().plusMinutes(5)); + var next=new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_DETAIL,0,"A","C/DO",INTERNAL);success(next,Operation.OUTBOUND_DETAIL); + assertThat(budget().reservedHttp).isEqualTo(8822);assertThat(budget().confirmedHttp).isEqualTo(8822);assertThat(budget().dailyLimit).isZero(); + assertThat(businessBudget().http).isEqualTo(8822);assertThat(businessBudget().confirmedHttp).isEqualTo(8822); + assertThat(businessBudget().continuous).isTrue();assertThat(businessBudget().deadlineAtUtc).isNull();assertThat(businessBudget().startedAtUtc).isEqualTo(started); + verify(mapper,never()).terminateBusinessTasks(any(),any()); + } + @Test void continuousRootSwitchOffRejectsNewReserveAndPreparedAuthorizeButAccountsLateCompletion(){ + continuousBusinessRoot();cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2)); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + assertThat(budget().reservedHttp).isZero(); + cfg.setContinuousBusinessCollectionEnabled(true);var c=reserve(Operation.WAREHOUSES,1,null,null); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + assertThat(budget().pending.consumed).isFalse(); + cfg.setContinuousBusinessCollectionEnabled(true);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + cfg.setContinuousBusinessCollectionEnabled(false);cfg.setCollectionMode("OFF");clock.advance(86400); + var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null); + var restarted=new SelfopEgressService(mapper,cfg,clock);restarted.complete(completion);restarted.complete(completion); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1); + assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isEqualTo(1); + } + @Test void continuousRootInProductionAlsoRequiresSwitchAndValidPersistedPlannerIdentity(){ + continuousBusinessRoot();cfg.setEnvironment("production");cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2)); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + cfg.setContinuousBusinessCollectionEnabled(true);task.setRequestedBy("manual-operator"); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); + assertThat(businessBudget().confirmedHttp).isEqualTo(1); + task.setStatus("DEAD");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_STOPPED"); + } + @Test void continuousRootAuthorizeStillChecksFenceStopAndPendingExpiry(){ + continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null); + account.setFenceToken(8L); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_FENCE_LOST"); + account.setFenceToken(7L);clock.advance(121); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); + assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().confirmedHttp).isZero(); + var b=businessBudget();b.stopReason="BUSINESS_MANUAL_STOP";task.setBusinessBudgetJson(SelfopSupport.json(b)); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_MANUAL_STOP"); + assertThat(task.getStatus()).isEqualTo("DEAD"); + } + @Test void continuousRootStillPreservesIntervalSingleFlightAndUnknownOutcomeAfterRestart(){ + continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_REQUEST_INFLIGHT"); + assertThat(budget().reservedHttp).isEqualTo(1); + service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT"); + clock.advance(1);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT"); + clock.advance(1);var pending=reserve(Operation.OUTBOUND_QUERY,1,null,null); + service.authorize(new AuthorizationRequest(pending,Operation.OUTBOUND_QUERY,PROVIDER));clock.advance(86400); + account.setLeaseUntilUtc(now().plusMinutes(5));var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(1);assertThat(budget().pending.abandoned).isTrue(); + assertThat(account.getApiPauseReason()).isEqualTo("REQUEST_OUTCOME_UNKNOWN"); + } + @Test void continuousRootActual429AndAuthorizationFailureKeepAccountProtections(){ + continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null)); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + clock.advance(cfg.getCooldownSeconds()+1);var next=reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO");service.authorize(new AuthorizationRequest(next,Operation.OUTBOUND_DETAIL,PROVIDER)); + service.complete(new CompletionRequest(next,Operation.OUTBOUND_DETAIL,PROVIDER,1,"LINGXING_WMS_AUTHORIZATION_REJECTED",401,null,true,null)); + clock.advance(86400);account.setLeaseUntilUtc(now().plusMinutes(5)); + assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");assertThat(budget().rateLimitFailures).isEqualTo(1); + assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2); + } @Test void businessSurpassesPriorDailyAndRootHttpCapsAcrossRestartWithoutRenewingDeadline(){ testBusiness(5000,300);cfg.setDailyRequestBudget(12000);cfg.setTransferredDailyHttpCount(12000); var rootBudget=businessBudget();rootBudget.maxHttp=5000;rootBudget.http=5000;rootBudget.confirmedHttp=5000;task.setBusinessBudgetJson(SelfopSupport.json(rootBudget)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java index 63bb5f6eb..9364a17bc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java @@ -33,10 +33,40 @@ class SelfopSalesPublisherTest { run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);run.setWarehouseDailyReady(true); run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2)); run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0)); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(run.getCoveredFromUtc(),run.getCoveredUntilUtc()))); task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED"); when(locks.lockScope(1L)).thenReturn(scope); when(locks.lockRun(10L)).thenReturn(run); when(locks.lockTask(20L)).thenReturn(task); } private void enableSyntheticPublication() { config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSalesPublicationEnabled(true); config.setQuantityUnit("PCS"); } + @Test void disconnectedTargetPublishesOnlyWithFrozenProofAndKeepsGapUnknown(){ + enableSyntheticPublication();var first=LocalDateTime.of(2026,10,6,16,0);var latest=LocalDateTime.of(2026,10,11,16,0); + run.setCoveredFromUtc(first);run.setCoveredUntilUtc(first.plusDays(1)); + task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1)); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1)))); + assertEquals("SELFOP_TASK_COVERAGE_UNPROVEN",assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)).getMessage()); + noWrites(); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1)),new SelfopSalesCoverage.Interval(latest,latest.plusDays(1)))); + publisher.publish(1,10,"worker",3);assertEquals("SUCCEEDED",task.getStatus()); + assertEquals(first.plusDays(1),scope.getDiscoveryUntilUtc()); + assertFalse(SelfopSalesCoverage.contains(SelfopSalesCoverage.evidence(run),first,latest.plusDays(1))); + } + @Test void latestDayCanPublishBeforeOldPlannedAnchorIsFilled(){ + enableSyntheticPublication();var latest=LocalDateTime.of(2026,10,11,16,0); + scope.setInitialFromUtc(latest.minusMonths(2));run.setCoveredFromUtc(null);run.setCoveredUntilUtc(null);run.setCoverageStatus("UNKNOWN"); + task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1)); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(latest,latest.plusDays(1)))); + publisher.publish(1,10,"worker",3);assertEquals("SUCCEEDED",task.getStatus()); + assertEquals(latest.minusMonths(2),scope.getInitialFromUtc());assertNull(run.getCoveredUntilUtc()); + } + @Test void reuseCannotCompleteLatestDayUsingOnlyOldIsland(){ + enableSyntheticPublication();var first=LocalDateTime.of(2026,10,6,16,0);var latest=LocalDateTime.of(2026,10,11,16,0); + var published=new SelfopSyncRun();published.setId(9L);published.setScopeId(1L);published.setScopeHash(scope.getScopeHash()); + published.setExecutionPurpose("BUSINESS");published.setStatus("PUBLISHED");published.setWarehouseDailyReady(true);published.setCalculatedFactRevision(7L); + published.setScopeSnapshotJson("{}");SelfopSalesCoverage.freeze(published,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1)))); + when(runs.selectById(9L)).thenReturn(published);task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1)); + assertEquals("SELFOP_TASK_COVERAGE_UNPROVEN",assertThrows(IllegalStateException.class,()->publisher.reuse(1,10,9,"worker",3)).getMessage()); + assertEquals("RUNNING",task.getStatus());verify(tasks,never()).update(any(),any()); + } private void noWrites() { verifyNoInteractions(scopes,runs,tasks); assertEquals(9L,scope.getPublishedRunId()); } @Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java index 2d9e99d34..91b1f9089 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java @@ -16,6 +16,95 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class SelfopSchedulePlannerTest { + @Test void continuousInventoryWaitsForTodaysSlotAndNeverBackfillsPreviousDays(){ + var state=continuousState(); + var manual7=dayRoot("RUNNING",7,"manual:7");var manual6=dayRoot("READY",6,"manual:6"); + state.add(manual7);state.add(manual6); + planner.planContinuous(LocalDateTime.of(2026,10,12,23,59)); + assertThat(state).noneMatch(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())); + planner.planContinuous(LocalDateTime.of(2026,10,13,0,0)); + var inventory=state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())).toList(); + assertThat(inventory).hasSize(1);assertThat(inventory.get(0).getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026,10,13,0,0)); + assertThat(inventory.get(0).getNextRetryAtUtc()).isEqualTo(inventory.get(0).getPlannedAtUtc()); + assertThat(SelfopCollectionPolicy.continuousBusinessRoot(inventory.get(0))).isTrue(); + planner.planContinuous(LocalDateTime.of(2026,10,13,12,0)); + planner.planContinuous(LocalDateTime.of(2026,10,13,16,0)); + assertThat(state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole()))).hasSize(1); + planner.planContinuous(LocalDateTime.of(2026,10,14,0,0)); + assertThat(state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole()))).hasSize(2); + assertThat(manual7.getStatus()).isEqualTo("RUNNING");assertThat(manual6.getStatus()).isEqualTo("READY"); + verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(tasks,never()).updateById(any(SelfopSyncTask.class)); + } + @Test void continuousInventoryRegistersOneOverdueSlotPerConfiguredWarehouse(){ + var state=continuousState();config.getWarehouses().put("WH-B",new SelfopAnalyticsProperties.Warehouse()); + var outboundA=scope(10L,"WH-A","OUTBOUND");outboundA.setBusinessTimezone("Asia/Shanghai");outboundA.setSourceTimezone("Asia/Shanghai"); + var outboundB=scope(20L,"WH-B","OUTBOUND");outboundB.setBusinessTimezone("Asia/Shanghai");outboundB.setSourceTimezone("Asia/Shanghai"); + doAnswer(call->{ + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper query=call.getArgument(0);query.getSqlSegment(); + return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(inventoryScope(11L,"WH-A"),inventoryScope(21L,"WH-B")):List.of(outboundA,outboundB);}).when(scopes).selectList(any()); + planner.planContinuous(LocalDateTime.of(2026,10,13,12,0));planner.planContinuous(LocalDateTime.of(2026,10,13,12,1)); + var inventory=state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())).toList(); + assertThat(inventory).hasSize(2);assertThat(inventory).extracting(SelfopSyncTask::getWarehouseCode).containsExactlyInAnyOrder("WH-A","WH-B"); + assertThat(inventory).allMatch(t->t.getPlannedAtUtc().equals(LocalDateTime.of(2026,10,13,0,0))&&SelfopCollectionPolicy.continuousBusinessRoot(t)); + } + @Test void continuousPoolPersistsCompleteDaysNewestFirstAndNeverChangesCoverageAnchor(){ + var state=continuousState(); + planner.planContinuous(LocalDateTime.of(2026,10,13,4,0)); + assertThat(state).hasSize(62); + assertThat(state.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,11,16,0)); + assertThat(state.get(60).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,16,0)); + assertThat(state).allMatch(SelfopCollectionPolicy::continuousBusinessRoot); + verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(taskService,never()).initialize(); + planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(62); + planner.planContinuous(LocalDateTime.of(2026,10,13,16,4));assertThat(state).hasSize(62); + planner.planContinuous(LocalDateTime.of(2026,10,13,16,5));assertThat(state).hasSize(63); + assertThat(state.get(62).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,12,16,0)); + assertThat(state).anyMatch(t->LocalDateTime.of(2026,8,12,16,0).equals(t.getQueryFromUtc())); + } + @Test void deadOldManualRootDoesNotMaskSuccessfulOrActiveDayAndContinuousFailureIsNotRecreated(){ + var state=continuousState(); + state.add(dayRoot("DEAD",12,"manual:old"));state.add(dayRoot("SUCCEEDED",12,"manual:success")); + state.add(dayRoot("RUNNING",11,"manual:running")); + state.add(dayRoot("DEAD",10,"manual:failed-only")); + planner.planContinuous(LocalDateTime.of(2026,10,13,4,0)); + assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).hasSize(60); + assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).noneMatch(t->LocalDateTime.of(2026,10,11,16,0).equals(t.getQueryFromUtc())); + var root=state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot).findFirst().orElseThrow();root.setStatus("DEAD"); + int count=state.size();planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(count); + } + @Test void continuousTickReReadsOneTaskAfterEverySafeDispatch(){ + continuousState();var subject=spy(planner);doNothing().when(subject).planContinuous(any()); + var old=dayRoot("RETRY_WAIT",10,"manual:old");old.setId(10L);old.setRequestedBy("operator"); + var fresh=dayRoot("READY",12,"manual:new");fresh.setId(12L);fresh.setRequestedBy("operator"); + when(taskService.due(1)).thenReturn(List.of(old),List.of(fresh)); + subject.tick();subject.tick(); + var order=inOrder(taskService,execution);order.verify(taskService).due(1);order.verify(execution).execute(10L); + order.verify(taskService).due(1);order.verify(execution).execute(12L);verify(taskService,never()).due(10); + } + private List continuousState(){ + directTransactions();config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true); + config.setSourceAccount("synthetic");config.setSourceQueryTimezone("Asia/Shanghai"); + config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + var scope=scope(10L,"WH-A","OUTBOUND");scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai"); + scope.setScopeHash("a".repeat(64));scope.setInitialFromUtc(LocalDateTime.of(2026,10,7,16,0)); + var stock=inventoryScope(11L,"WH-A"); + when(scopes.selectList(any())).thenAnswer(call->{ + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper query=call.getArgument(0);query.getSqlSegment(); + return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(stock):List.of(scope);}); + var account=new SelfopOutboundDiscoveryScope();account.setId(30L);when(taskService.initializeAccount()).thenReturn(account); + var state=new ArrayList();when(tasks.selectList(any())).thenAnswer(call->new ArrayList<>(state)); + when(taskService.create(any())).thenAnswer(call->{SelfopSyncTask task=call.getArgument(0);state.add(task);return task;});return state; + } + private SelfopSyncTask dayRoot(String status,int day,String key){ + var root=controlled(100L+day,"ACCOUNT_DISCOVERY","BACKFILL",key);root.setDiscoveryScopeId(30L);root.setScopeHash("a".repeat(64));root.setStatus(status); + var from=LocalDateTime.of(2026,10,day,0,0);root.setQueryFromUtc(from.minusHours(8));root.setQueryUntilUtc(from.plusDays(1).minusHours(8)); + root.setQueryScopeJson(com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.json(new SelfopDiscoveryService.Query(from.toString(),from.plusDays(1).toString(),null,null,null)));return root; + } + private SelfopSyncScope inventoryScope(long id,String warehouse){ + var scope=scope(id,warehouse,"INVENTORY");scope.setSourceAccount("synthetic");scope.setScopeHash("b".repeat(64)); + scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai"); + scope.setScopeJson(com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.json(Map.of("sourceAccount","synthetic","warehouseCode",warehouse,"dataFamily","INVENTORY","businessTimezone","Asia/Shanghai")));return scope; + } private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); private final SelfopTaskService taskService = mock(SelfopTaskService.class); private final SelfopExecutionService execution = mock(SelfopExecutionService.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java index c9895aede..760009638 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java @@ -14,6 +14,37 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class SelfopTaskServiceTest { + @Test void automaticInventoryRootRequiresFrozenInventoryScopeAndDoesNotAdmitOldScheduledTasks(){ + config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true); + var scope=scope("INVENTORY");scope.setSourceAccount("synthetic-account");scope.setWarehouseCode("WH-A");scope.setScopeHash("b".repeat(64)); + scope.setScopeJson(json(Map.of("sourceAccount","synthetic-account","warehouseCode","WH-A","dataFamily","INVENTORY","businessTimezone","Asia/Shanghai"))); + when(scopes.selectById(10L)).thenReturn(scope); + var task=task("READY");task.setTaskType("SCHEDULED");task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER); + task.setScopeHash(scope.getScopeHash());task.setQueryScopeJson(scope.getScopeJson());task.setPlannedAtUtc(LocalDateTime.of(2026,10,14,0,0)); + task.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12)); + service.create(task);assertThat(task.getBusinessRootTaskId()).isEqualTo(task.getId()); + assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true)).isTrue(); + assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,false)).isFalse(); + task.setRequestKey("slot:old");assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true)).isFalse(); + task.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12)); + task.setQueryScopeJson(json(Map.of("dataFamily","OUTBOUND")));assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isFalse(); + } + @Test void continuousRootCreationFreezesUnboundedSessionAndUsesAccountQueue(){ + config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true);config.setSourceAccount("synthetic-account"); + var account=new SelfopOutboundDiscoveryScope();account.setId(20L);account.setNextSequence(1L);account.setNextObservationSequence(1L); + when(locks.lockDiscovery(20L)).thenReturn(account); + var task=new SelfopSyncTask();task.setSourceAccount("synthetic-account");task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType("BACKFILL");task.setDiscoveryScopeId(20L); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash("a".repeat(64)); + task.setRequestKey("continuous:outbound:2026-10-12:"+"a".repeat(12));task.setRequestHash("digest"); + task.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));task.setQueryUntilUtc(LocalDateTime.of(2026,10,12,16,0)); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query("2026-10-12T00:00","2026-10-13T00:00",null,null,null))); + service.create(task);assertThat(task.getBusinessRootTaskId()).isEqualTo(task.getId()); + var budget=read(task.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); + assertThat(budget.continuous).isTrue();assertThat(budget.deadlineAtUtc).isNull();assertThat(budget.limit(utcNow().plusYears(1))).isNull(); + service.due(1);verify(locks).dueContinuous(any(),eq(1),eq("synthetic-account"));verify(locks,never()).due(any(),anyInt(),anyString()); + task.setScopeHash("invalid-hash");assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isFalse(); + } private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); @@ -414,6 +445,19 @@ class SelfopTaskServiceTest { assertThatThrownBy(()->service.waitForBudget(1L,20L,utcNow().plusDays(1),lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class)); } + @Test void continuousFailureStopsInsteadOfBecomingAnotherScheduledRetry(){ + var root=boundedRoot(0,utcNow().plusMinutes(3));config.setContinuousBusinessCollectionEnabled(true); + root.setTaskRole("ACCOUNT_DISCOVERY");root.setTaskType("BACKFILL");root.setScopeId(null);root.setWarehouseCode(null);root.setDiscoveryScopeId(99L); + root.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);root.setScopeHash("a".repeat(64)); + root.setRequestKey("continuous:outbound:2026-10-12:"+"a".repeat(12));root.setStatus("RUNNING"); + root.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));root.setQueryUntilUtc(LocalDateTime.of(2026,10,12,16,0)); + root.setQueryScopeJson(json(new SelfopDiscoveryService.Query("2026-10-12T00:00","2026-10-13T00:00",null,null,null))); + root.setBusinessBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.freezeContinuous(utcNow())); + var run=run("FETCHING");when(locks.lockRun(20L)).thenReturn(run); + service.fail(1L,20L,"LINGXING_WMS_RATE_LIMITED",true,new SelfopLeaseService.Lease(99L,"owner",7L,true)); + assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull(); + assertThat(run.getStatus()).isEqualTo("FAILED");assertThat(run.getErrorCode()).isEqualTo("LINGXING_WMS_RATE_LIMITED"); + } @Test void egressStopReasonSurvivesExecutorFailureHandling() { var root=boundedRoot(7,utcNow().plusMinutes(3)); var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); From f1fb5662ca93ddce3c74d323c6650d123ce59572 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 21:39:51 +0800 Subject: [PATCH 57/80] fix(self-operated): remove business collection deadline limits --- .../config/LingxingEgressProperties.java | 2 +- .../provider/lingxing/LingxingEgressGate.java | 12 ++-- .../lingxing/LingxingEgressGateTest.java | 24 +++++-- .../query/CollectionQueryService.java | 7 +- .../request/SelfopBusinessBudget.java | 11 ++- .../request/SelfopEgressService.java | 8 +-- .../analytics/sync/SelfopTaskService.java | 52 +++++--------- .../query/CollectionQueryServiceTest.java | 20 +++--- .../SelfopBusinessBudgetPersistenceTest.java | 2 +- .../request/SelfopBusinessBudgetTest.java | 16 +++-- .../request/SelfopEgressServiceTest.java | 70 +++++++++++++------ .../analytics/sync/SelfopTaskServiceTest.java | 40 +++++++---- 12 files changed, 150 insertions(+), 114 deletions(-) diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java index dbcdeeb46..5a2ec5ca4 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java @@ -7,7 +7,7 @@ import java.net.URI; import java.time.Duration; import java.time.Instant; -/** An operator-attested exclusive window; it is not a distributed account lock. */ +/** Exclusive grant identity; externalExclusiveUntil bounds VALIDATION only, not BUSINESS. */ @ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress") public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode, @DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId, diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java index aafb52dd6..df05dea51 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java @@ -11,7 +11,7 @@ import java.time.Instant; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -/** Local serialization supplements, never replaces, the externally attested window and durable budget. */ +/** Local serialization supplements the exclusive grant and durable account protection. */ @Component public final class LingxingEgressGate { private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class); @@ -62,7 +62,7 @@ public final class LingxingEgressGate { || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() || !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) || permit.expiresAt().isAfter(clock.instant().plusSeconds(30)) - || (!continuousBusiness() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) { + || (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) { recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID"); } // A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state. @@ -108,11 +108,13 @@ public final class LingxingEgressGate { || !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID"); if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank() || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId()) - || (!continuousBusiness() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()))) + || (!business() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()))) || properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED"); } - private boolean continuousBusiness() { - return properties.collectionMode() == CollectionMode.BUSINESS && properties.continuousBusinessCollectionEnabled(); + private boolean business() { + // The account authority validates the original task identity, including manual roots. + // BUSINESS has no operator session cutoff; each individual permit remains finite. + return properties.collectionMode() == CollectionMode.BUSINESS; } private static boolean identifier(String value, int maximum) { return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java index 92ae145ba..006c4955f 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java @@ -97,13 +97,27 @@ class LingxingEgressGateTest { .hasMessageContaining("ACCOUNT_PAUSED"); assertThat(ledger.authorizations).isEqualTo(1); } - @Test void disabledContinuousBusinessStillRequiresUnexpiredGrant() { + @Test void manualBusinessWithContinuousFlagOffCrossesOldCutoffButRejectsExpiredSinglePermit() { var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", "demo-account", "demo-grant", NOW, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false), ledger, clock, clock::advanceMillis); - assertThatThrownBy(() -> gate.execute(businessContext("disabled"), Operation.WAREHOUSES, HASH, h -> true)) - .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); - assertThat(ledger.authorizations).isZero(); + var manual = new EgressContext("demo-account", CollectionMode.BUSINESS, "6", "selfop-6", "manual-after-cutoff", "demo-grant"); + assertThat(gate.execute(manual, Operation.OUTBOUND_DETAIL, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.context()).isEqualTo(manual)); + ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, + clock.instant(), true, true); + assertThatThrownBy(() -> gate.execute(new EgressContext("demo-account", CollectionMode.BUSINESS, "6", "selfop-6", "manual-expired-permit", "demo-grant"), + Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_PERMIT_INVALID"); + assertThat(ledger.authorizations).isEqualTo(2);assertThat(ledger.completions).hasSize(1); + } + @Test void manualBusinessWithoutGrantCutoffStillRejectsPermitExpiringWhileSigning() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false), + ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> gate.execute(businessContext("manual-signing-expired"), Operation.OUTBOUND_DETAIL, HASH, + h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero()); } @Test void continuousBusinessRetains1200MillisSpacingAndOneHttpPerPermit() { var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", @@ -179,8 +193,6 @@ class LingxingEgressGateTest { assertThatThrownBy(()->gate.execute(context("wrong-purpose"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID"); assertThatThrownBy(()->gate.execute(new EgressContext("other-account",CollectionMode.BUSINESS,"root","session","wrong-account","demo-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EGRESS_CONTEXT_INVALID"); assertThatThrownBy(()->gate.execute(new EgressContext("demo-account",CollectionMode.BUSINESS,"root","session","wrong-grant","other-grant"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); - clock.advanceMillis(3600000); - assertThatThrownBy(()->gate.execute(businessContext("expired-window"),Operation.WAREHOUSES,HASH,h->true)).hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); assertThat(ledger.authorizations).isZero();assertThat(ledger.completions).isEmpty(); } @Test void testBusinessUnconfirmedCompletionStillRequiresManualReviewAfterTimePasses() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java index 02af43091..03844c131 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java @@ -304,12 +304,11 @@ public class CollectionQueryService { if(continuous&&!continuousBusiness())return "CONTINUOUS_COLLECTION_DISABLED"; if(budget==null||maximum==null||maximum<0||maximum>Integer.MAX_VALUE||reserved==null||reserved>Integer.MAX_VALUE ||confirmed==null||confirmed>reserved||started==null - ||(continuous?budget.hasNonNull("deadlineAtUtc"):deadline==null||!deadline.isAfter(started))) + ||(budget.hasNonNull("deadlineAtUtc")&&(continuous||deadline==null||!deadline.isAfter(started)))) return "BUSINESS_SESSION_BUDGET_MISSING"; JsonNode stopped=budget.get("stopReason"); if(stopped!=null&&!stopped.isNull()) return "BUSINESS_SESSION_STOPPED"; - if(!continuous&&!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE"; return null; } private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) { @@ -327,7 +326,7 @@ public class CollectionQueryService { } private String safeMessage(String code,String error) { if("BUSINESS_SESSION_HTTP_LIMIT".equals(code))return "本次正式采集会话的请求预算已耗尽,根任务及关联子任务已停止,不能恢复原任务"; - if("BUSINESS_SESSION_DEADLINE".equals(code))return "本次正式采集会话已到截止时间,根任务及关联子任务已停止,不能恢复原任务"; + if("BUSINESS_SESSION_DEADLINE".equals(code))return "该任务曾因旧采集截止时间停止,需核对原检查点和账户保护状态后受控恢复"; if(error==null)return null; // Errors are already sanitized by the write side; avoid exposing arbitrary payloads here. return "采集尝试失败,请根据错误码查看受控诊断记录"; @@ -354,7 +353,7 @@ public class CollectionQueryService { if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED"; if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() - ||(!continuousBusiness()&&(config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))))return "EXCLUSIVE_GRANT_NOT_VALID"; + ||("VALIDATION".equals(config.getCollectionMode())&&(config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))))return "EXCLUSIVE_GRANT_NOT_VALID"; if("BUSINESS".equals(config.getCollectionMode()))return null; if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED"; if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java index ec352f292..7802f75a8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java @@ -3,21 +3,21 @@ package com.baiyue.lastleg.selfoperated.analytics.request; import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; import java.time.LocalDateTime; -/** BUSINESS session accounting and time ceiling, shared by the root and descendants. */ +/** BUSINESS session accounting and explicit stops, shared by the root and descendants. */ public final class SelfopBusinessBudget { /** Recorded field only; BUSINESS has no HTTP-count ceiling. New sessions write zero. */ public int maxHttp; public int http, confirmedHttp; public boolean continuous; + /** Existing deadlines remain recorded evidence, never an execution limit. */ public LocalDateTime startedAtUtc, deadlineAtUtc; public String stopReason; - public static String freeze(LocalDateTime now, int seconds) { - if (now == null || seconds < 1) + public static String freeze(LocalDateTime now) { + if (now == null) throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID"); var budget = new SelfopBusinessBudget(); budget.startedAtUtc = now; - budget.deadlineAtUtc = now.plusSeconds(seconds); return SelfopSupport.json(budget); } @@ -29,7 +29,7 @@ public final class SelfopBusinessBudget { public void validate() { if (http < 0 || confirmedHttp < 0 || confirmedHttp > http - || startedAtUtc == null || (continuous ? deadlineAtUtc != null : deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc)) + || startedAtUtc == null || deadlineAtUtc != null && (continuous || !deadlineAtUtc.isAfter(startedAtUtc)) || stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}")) throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null); } @@ -37,7 +37,6 @@ public final class SelfopBusinessBudget { public String limit(LocalDateTime now) { validate(); if (stopReason != null) return stopReason; - if (!continuous && !now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; return null; } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index ebf61f6ca..ad6016d53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -42,9 +42,8 @@ public class SelfopEgressService { if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); - if(!continuousBusiness(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); + if("VALIDATION".equals(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; - if(business!=null&&business.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.deadlineAtUtc; budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now; if(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);} if(business!=null){business.http++;saveBusiness(root,business);} @@ -58,10 +57,9 @@ public class SelfopEgressService { var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); if(hasBusinessSession(root)){ var b=business(root);verifyBusinessTask(task,root); - // Authorization preserves the root time/stop fence independently of request accounting. + // Explicit root stops remain independent of request accounting and short permit expiry. String limit=b.limit(now); if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);} - if(b.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc; } verify(task,run,root,account,now); if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); @@ -127,7 +125,7 @@ public class SelfopEgressService { if("OFF".equals(config.getCollectionMode())||!Objects.equals(config.getCollectionMode(),purpose)||!("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose)))throw reject("EGRESS_MODE_REJECTED"); if("BUSINESS".equals(purpose)&&!("production".equals(config.getEnvironment())||"test".equals(config.getEnvironment())))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH"); - if(grant==null||grant.isBlank()||!Objects.equals(grant,config.getExclusiveGrantId())||(!continuousBusiness(purpose)&&(config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())))||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + if(grant==null||grant.isBlank()||!Objects.equals(grant,config.getExclusiveGrantId())||("VALIDATION".equals(purpose)&&(config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())))||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); if(("VALIDATION".equals(purpose)&&config.getDailyRequestBudget()<1)||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); } private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java index 8effe516c..f045b98f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -93,7 +93,7 @@ public class SelfopTaskService { public SelfopSyncTask create(SelfopSyncTask task) { identity(task.getRequestKey(),128); var existing=tasks.selectOne(new QueryWrapper() .eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey())); - if(existing!=null){require(existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(existing,!localBusinessWork(existing));return existing;} + if(existing!=null){require(existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(existing);return existing;} if(task.getExecutionPurpose()==null)task.setExecutionPurpose(configuredPurpose(config)); requireExecution(config,task.getExecutionPurpose()); var now=utcNow();task.setId(IdWorker.getId());task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now); @@ -121,13 +121,13 @@ public class SelfopTaskService { }else if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); task.setBusinessRootTaskId(task.getId()); - task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds())); + task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now)); }else { var parent=businessParent(task); require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); if("MANUAL_RETRY".equals(task.getTaskType()))require("SUCCEEDED".equals(task.getStatus())&&task.getRequestKey().startsWith("retry:")&&task.getUpstreamTaskId()!=null,"SELFOP_TEST_MANUAL_TASK_REQUIRED"); require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); - businessGuard(parent,!"WAREHOUSE_SYNC".equals(task.getTaskRole()));task.setBusinessRootTaskId(parent.getBusinessRootTaskId()); + businessGuard(parent);task.setBusinessRootTaskId(parent.getBusinessRootTaskId()); } } task.setStatus(task.getStatus()==null?"READY":task.getStatus()); @@ -138,13 +138,13 @@ public class SelfopTaskService { } try{tasks.insert(task);}catch(DuplicateKeyException ignored){existing=tasks.selectOne(new QueryWrapper() .eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey()).last("FOR UPDATE")); - require(existing!=null&&existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(existing,!localBusinessWork(existing));return existing;} + require(existing!=null&&existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(existing);return existing;} return task; } @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) { lockBusinessAccount(taskId);leases.verify(lease); - if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));} + if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview);} var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE"); checkTaskLease(task,lease); requireExecution(config,task.getExecutionPurpose()); @@ -186,14 +186,14 @@ public class SelfopTaskService { @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void yield(long taskId,long runId,SelfopLeaseService.Lease lease) { lockBusinessAccount(taskId);leases.verify(lease); - guardBeforeTaskLock(taskId,true); + guardBeforeTaskLock(taskId); var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setStatus("YIELDED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(now.plusSeconds(1));task.setUpdatedAt(now);tasks.updateById(task); } @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void fail(long taskId,long runId,String code,boolean retryable,SelfopLeaseService.Lease lease) { lockBusinessAccount(taskId);leases.verify(lease); - try{guardBeforeTaskLock(taskId,true);}catch(BusinessSessionStoppedException stopped){return;} + try{guardBeforeTaskLock(taskId);}catch(BusinessSessionStoppedException stopped){return;} var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("FAILED");run.setErrorCode(code); run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); // A yielded execution is not a failed request; finite failures are counted separately. @@ -218,14 +218,14 @@ public class SelfopTaskService { lockBusinessAccount(id); var commandQuery=new QueryWrapper().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey); var prior=tasks.selectOne(commandQuery); - if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(preview,!localBusinessWork(preview));return preview;} + if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(preview);return preview;} // Match executor lock ordering: account/warehouse scope first, task second. String activeOwner;LocalDateTime activeUntil; if("ACCOUNT_DISCOVERY".equals(preview.getTaskRole())){var scope=locks.lockDiscovery(preview.getDiscoveryScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} else{var scope=locks.lockScope(preview.getScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} prior=tasks.selectOne(new QueryWrapper().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey).last("FOR UPDATE")); if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return tasks.selectById(id);} - businessGuard(preview,!localBusinessWork(preview)); + businessGuard(preview); require(SelfopLeaseService.available(activeOwner,activeUntil,utcNow()),"SELFOP_RETRY_LEASE_BUSY"); var original=locks.lockTask(id);require(original!=null,"SELFOP_TASK_MISSING"); requireBusiness(original);requireExecution(config,original.getExecutionPurpose()); @@ -245,7 +245,7 @@ public class SelfopTaskService { public List due(int limit){return continuousBusiness(config)?locks.dueContinuous(utcNow(),limit,config.getSourceAccount()):locks.due(utcNow(),limit,config.getCollectionMode());} @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) { - lockBusinessAccount(taskId);leases.verify(lease);guardBeforeTaskLock(taskId,false);var task=locks.lockTask(taskId);var run=locks.lockRun(runId); + lockBusinessAccount(taskId);leases.verify(lease);guardBeforeTaskLock(taskId);var task=locks.lockTask(taskId);var run=locks.lockRun(runId); checkRunLease(task,run,lease); require("READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY"); run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); @@ -254,7 +254,7 @@ public class SelfopTaskService { } @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) { - lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run); + lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview);}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run); SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("YIELDED");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(until);task.setUpdatedAt(utcNow());tasks.updateById(task); } @@ -264,17 +264,17 @@ public class SelfopTaskService { public CollectionCheckpoint checkpoint(SelfopSyncTask observedTask,SelfopSyncRun observedRun,SelfopLeaseService.Lease lease) { long taskId=observedTask.getId();lockBusinessAccount(taskId);leases.verify(lease); if("test".equals(config.getEnvironment())||continuousBusiness(config)) { - var preview=tasks.selectById(taskId);require(preview!=null,"SELFOP_TASK_MISSING");businessGuard(preview,false,false); + var preview=tasks.selectById(taskId);require(preview!=null,"SELFOP_TASK_MISSING");businessGuard(preview,false); } var task=locks.lockTask(taskId);var run=locks.lockRun(observedRun.getId());checkRunLease(task,run,lease); requireBusiness(task);requireBusiness(run); require("RUNNING".equals(task.getStatus())&&"FETCHING".equals(run.getStatus()),"SELFOP_COLLECTION_TERMINATED"); return new CollectionCheckpoint(task,run); } - /** Only TEST manual collection has a session ceiling; production retains its resumable scheduler. */ + /** Explicit session stops remain binding after removal of artificial BUSINESS deadlines. */ public static final class BusinessSessionStoppedException extends com.baiyue.common.core.exception.BizException { private final String reason; - BusinessSessionStoppedException(String reason){super("本次测试采集会话已结束或预算无效,请核对记录后新建有界任务");this.reason=reason;} + BusinessSessionStoppedException(String reason){super("本次采集会话已停止或计数无效,请核对停止原因与原检查点");this.reason=reason;} public String reason(){return reason;} } private boolean testBusiness(SelfopSyncTask task){return task!=null&&("test".equals(config.getEnvironment())||continuousBusiness(config))&&"BUSINESS".equals(task.getExecutionPurpose());} @@ -290,27 +290,12 @@ public class SelfopTaskService { var account=accounts.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount())); require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId()); } - private void guardBeforeTaskLock(long id,boolean checkDeadline){ + private void guardBeforeTaskLock(long id){ if(!"test".equals(config.getEnvironment())&&!continuousBusiness(config))return; - var task=tasks.selectById(id);businessGuard(task,checkDeadline); + var task=tasks.selectById(id);businessGuard(task); } - private boolean localBusinessWork(SelfopSyncTask task){ - if(!testBusiness(task)||!"WAREHOUSE_SYNC".equals(task.getTaskRole()))return false; - var scope=scopes.selectById(task.getScopeId()); - if(scope==null||!"OUTBOUND".equals(scope.getDataFamily()))return false; - if("REBUILD".equals(task.getTaskType())||"APPLIED".equals(task.getApplyState()))return true; - if(task.getRoutingManifestJson()==null)return false; - Map manifest; - try{manifest=read(task.getRoutingManifestJson(),Map.class);}catch(IllegalArgumentException invalid){return false;} - if(manifest==null||!Boolean.TRUE.equals(manifest.get("directoryComplete"))||!(manifest.get("objects") instanceof Map objects))return false; - return objects.values().stream().allMatch(value->{ - if(!(value instanceof Map entry))return false; - Object id=entry.get("effectiveStageId"); - return (id instanceof Long||id instanceof Integer)&&((Number)id).longValue()>0; - }); - } - private void businessGuard(SelfopSyncTask task,boolean checkDeadline){businessGuard(task,checkDeadline,true);} - private void businessGuard(SelfopSyncTask task,boolean checkDeadline,boolean terminate){ + private void businessGuard(SelfopSyncTask task){businessGuard(task,true);} + private void businessGuard(SelfopSyncTask task,boolean terminate){ if(!testBusiness(task))return; require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED"); @@ -327,7 +312,6 @@ public class SelfopTaskService { var budget=read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);budget.validate(); require(!budget.continuous||(continuousBusiness(config)&&continuousBusinessRoot(root)),"SELFOP_CONTINUOUS_COLLECTION_DISABLED"); String reason=budget.stopReason; - if(reason==null&&checkDeadline&&!budget.continuous&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE"; if(reason==null&&"DEAD".equals(root.getStatus()))reason="BUSINESS_SESSION_STOPPED"; if(reason==null)return; if(!terminate)throw new BusinessSessionStoppedException(reason); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java index 6ffad22d3..a6f552238 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java @@ -137,7 +137,7 @@ class CollectionQueryServiceTest { assertFalse(rows.isEmpty());for(var row:rows){assertTrue(row.executionEnabled());assertNull(row.disabledReason());assertNull(row.nextScheduledAt()); assertEquals("INVENTORY".equals(row.dataFamily()),row.publicationEnabled());} config.setExclusiveGrantExpiresAtUtc(now.minusSeconds(1)); - for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("EXCLUSIVE_GRANT_NOT_VALID",row.disabledReason());} + for(var row:service.overview(q(null,null,null)).warehouses()){assertTrue(row.executionEnabled());assertNull(row.disabledReason());} config.setEnvironment("development"); for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("BUSINESS_ENVIRONMENT_REJECTED",row.disabledReason());} } @@ -157,9 +157,9 @@ class CollectionQueryServiceTest { assertEquals("EXCLUSIVE_GRANT_NOT_VALID",row.disabledReason()); } config.setPriorEgressClosed(true);config.setContinuousBusinessCollectionEnabled(false); - assertFalse(service.overview(q(null,null,null)).warehouses().get(0).executionEnabled()); + assertTrue(service.overview(q(null,null,null)).warehouses().get(0).executionEnabled()); } - @Test void continuousBudgetRequiresEnabledModeAndNoDeadlineWhileBoundedBudgetsStillExpire() { + @Test void plannerIdentityStillNeedsSwitchButManualRecordedDeadlineDoesNotExpire() { enableBusiness("test");config.setContinuousBusinessCollectionEnabled(true); String continuous="{\"continuous\":true,\"startedAtUtc\":\""+now.minusDays(2)+"\",\"maxHttp\":0,\"http\":10,\"confirmedHttp\":10,\"deadlineAtUtc\":null,\"stopReason\":null}"; when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous)); @@ -167,7 +167,9 @@ class CollectionQueryServiceTest { when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous.replace("\"deadlineAtUtc\":null","\"deadlineAtUtc\":\""+now.plusDays(1)+"\""))); assertEquals("BUSINESS_SESSION_BUDGET_MISSING",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.minusMinutes(1).toString(),null,10))); - assertEquals("BUSINESS_SESSION_DEADLINE",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + assertNull(service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous.replace("\"continuous\":true","\"continuous\":false"))); + assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions()); config.setContinuousBusinessCollectionEnabled(false); when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous)); assertEquals("CONTINUOUS_COLLECTION_DISABLED",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); @@ -234,14 +236,14 @@ class CollectionQueryServiceTest { enableBusiness("production");when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(null)); assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions()); } - @Test void expiredBudgetOnlyAllowsProvenLocalWorkButStoppedBudgetNeverDoes() { + @Test void recordedDeadlineDoesNotBlockRemoteOrLocalWorkButPersistedStopStillDoes() { enableBusiness("test"); when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FAILED",null))); for(boolean ready:List.of(false,true)) { when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.toString(),null,5),ready)); var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task(); - assertEquals(ready,task.latestAttempt().retryable()); - assertEquals(ready?null:"BUSINESS_SESSION_DEADLINE",task.actionBlockReason()); + assertTrue(task.latestAttempt().retryable()); + assertNull(task.actionBlockReason()); } when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.toString(),"BUSINESS_SESSION_DEADLINE",5),true)); assertEquals("BUSINESS_SESSION_STOPPED",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); @@ -253,8 +255,8 @@ class CollectionQueryServiceTest { now,null,now,null,"PARTIAL",null,null,code,null,now.minusMinutes(2),"A",1L,"hash"); when(mapper.runs(any(),eq(upper))).thenReturn(List.of(attempt)); String message=service.runs(q(null,null,null)).records().get(0).failureSummary(); - assertTrue(message.contains(code.endsWith("HTTP_LIMIT")?"请求预算已耗尽":"已到截止时间")); - assertTrue(message.contains("不能恢复原任务")); + assertTrue(message.contains(code.endsWith("HTTP_LIMIT")?"请求预算已耗尽":"旧采集截止时间")); + assertTrue(message.contains(code.endsWith("HTTP_LIMIT")?"不能恢复原任务":"受控恢复")); } } Attempt run(int attempt,String status,String snapshot) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java index c5a2b13e8..80fed9111 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java @@ -28,7 +28,7 @@ class SelfopBusinessBudgetPersistenceTest { } var tasks=session.getMapper(SelfopSyncTaskMapper.class); var stale=new SelfopSyncTask();stale.setId(1L);stale.setStatus("RUNNING");stale.setBusinessRootTaskId(1L); - stale.setBusinessBudgetJson(SelfopBusinessBudget.freeze(LocalDateTime.of(2026,10,13,0,0),120)); + stale.setBusinessBudgetJson(SelfopBusinessBudget.freeze(LocalDateTime.of(2026,10,13,0,0))); assertEquals(1,tasks.insert(stale)); // NEVER updates must still allow initial freezing. var charged=SelfopSupport.read(stale.getBusinessBudgetJson(),SelfopBusinessBudget.class); charged.http=2;charged.confirmedHttp=1; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java index 576a0ca4c..a8be7c63b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java @@ -17,23 +17,25 @@ class SelfopBusinessBudgetTest { } private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0); - @Test void serializedConsumptionAndDeadlineSurviveReload() { - var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,60),SelfopBusinessBudget.class); + @Test void recordedFiniteDeadlineSurvivesReloadWithoutStoppingExistingBusiness() { + var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start),SelfopBusinessBudget.class); + budget.deadlineAtUtc=start.plusSeconds(60); budget.http=2;budget.confirmedHttp=1; var restored=SelfopSupport.read(SelfopSupport.json(budget),SelfopBusinessBudget.class); assertEquals(start.plusSeconds(60),restored.deadlineAtUtc); assertEquals(2,restored.http);assertEquals(1,restored.confirmedHttp); assertEquals(0,restored.maxHttp);assertNull(restored.limit(start.plusSeconds(1))); - assertEquals("BUSINESS_SESSION_DEADLINE",restored.limit(start.plusSeconds(60))); + assertNull(restored.limit(start.plusSeconds(60)));assertNull(restored.limit(start.plusYears(1))); + assertFalse(restored.continuous); restored.stopReason="BUSINESS_SESSION_HTTP_LIMIT"; assertEquals(restored.stopReason,restored.limit(start.plusDays(1))); } - @Test void unlimitedHttpRetainsStrictDeadlineAndAccounting() { - assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(null,60)); - assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,0)); + @Test void newBusinessHasNoDeadlineAndRetainsStrictAccounting() { + assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(null)); assertThrows(EgressRejectedException.class,()->new SelfopBusinessBudget().validate()); - var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,60),SelfopBusinessBudget.class); + var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start),SelfopBusinessBudget.class); + assertNull(budget.deadlineAtUtc);assertFalse(budget.continuous); budget.confirmedHttp=1; assertThrows(EgressRejectedException.class,budget::validate); budget.confirmedHttp=12001;budget.http=12001;budget.maxHttp=5000; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index c8ccc609e..dbb6257be 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -34,17 +34,18 @@ class SelfopEgressServiceTest { cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("unknown");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED"); cfg.setCollectionMode("VALIDATION");cfg.setPriorEgressClosed(false);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper); } - @Test void testBusinessStillChargesPersistentAccountAndRequiresLiveExclusiveGrant(){ + @Test void businessStillChargesPersistentAccountAndRequiresMatchingGrantIdentity(){ testBusiness(5,300); var c=reserve(Operation.WAREHOUSES,1,null,null);success(c,Operation.WAREHOUSES); assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1); assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isEqualTo(1); cfg.setExclusiveGrantExpiresAtUtc(now()); - assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); - assertThat(budget().reservedHttp).isEqualTo(1); + success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); + cfg.setExclusiveGrantId("different");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + assertThat(budget().reservedHttp).isEqualTo(2); } @Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();} - @Test void continuousBusinessGrantExpiryDoesNotClipShortPendingOrPermitAndSwitchKeepsCounters(){ + @Test void businessGrantExpiryDoesNotClipShortPendingOrPermitRegardlessOfPlannerSwitch(){ testBusiness(5,300);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setEgressLeaseSeconds(120); var c=reserve(Operation.WAREHOUSES,1,null,null); @@ -54,7 +55,8 @@ class SelfopEgressServiceTest { restarted.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2); cfg.setContinuousBusinessCollectionEnabled(false); - assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + clock.advance(5);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); + assertThat(budget().reservedHttp).isEqualTo(3);assertThat(businessBudget().continuous).isFalse(); } @Test void continuousFlagNeverBypassesValidationWindowGrantIdentityOrHandoff(){ cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); @@ -64,15 +66,15 @@ class SelfopEgressServiceTest { cfg.setExclusiveGrantId("grant");cfg.setPriorEgressClosed(false); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper); } - @Test void continuousGrantDoesNotRenewFiniteManualRootOrRelaxFence(){ + @Test void oldManualDeadlineDoesNotClipPermitOrRelaxFence(){ testBusiness(5,10);cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); account.setFenceToken(8L); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_FENCE_LOST"); account.setFenceToken(7L);var c=reserve(Operation.WAREHOUSES,1,null,null); - assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc); + assertThat(budget().pending.expiresAtUtc).isAfter(businessBudget().deadlineAtUtc); clock.advance(10); - assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_SESSION_DEADLINE"); - assertThat(budget().pending.consumed).isFalse(); + assertThat(service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)).expiresAt()).isEqualTo(clock.instant().plusSeconds(30)); + assertThat(budget().pending.consumed).isTrue();assertThat(businessBudget().continuous).isFalse(); } @Test void continuousBusinessAcrossMidnightRetainsUnknownPendingAndReviewAcrossRestart(){ business();cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); @@ -188,7 +190,9 @@ class SelfopEgressServiceTest { } } private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");} - private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),seconds));} + private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L); + var recorded=new SelfopBusinessBudget();recorded.startedAtUtc=now();recorded.deadlineAtUtc=now().plusSeconds(seconds); + task.setBusinessBudgetJson(SelfopSupport.json(recorded));} private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);} private void continuousBusinessRoot(){ business();cfg.setEnvironment("test");cfg.setContinuousBusinessCollectionEnabled(true); @@ -298,7 +302,7 @@ class SelfopEgressServiceTest { verify(mapper,never()).terminateBusinessTasks(any(),any());verify(mapper,never()).terminateValidationRuns(any(),any(),any(),any()); } @Test void businessWithoutDailyQuotaStillUsesIntervalAndActual429Review(){ - testBusiness(1,300);cfg.setDailyRequestBudget(0);cfg.setBusinessMinRequestIntervalMs(1200); + testBusiness(1,1);clock.advance(2);cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setDailyRequestBudget(0);cfg.setBusinessMinRequestIntervalMs(1200); var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT"); @@ -306,28 +310,50 @@ class SelfopEgressServiceTest { service.complete(new CompletionRequest(next,Operation.OUTBOUND_QUERY,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null)); assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);assertThat(budget().rateLimitFailures).isEqualTo(1); assertThat(account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN"); + clock.advance(cfg.getCooldownSeconds()+1);var afterCooldown=reserve(Operation.WAREHOUSES,1,null,null); + service.authorize(new AuthorizationRequest(afterCooldown,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(afterCooldown,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_AUTHORIZATION_REJECTED",401,null,true,null)); + clock.advance(2);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW"); } - @Test void testBusinessDeadlineClipsPermitAndAuthorizeRechecksBeforeHttp(){ - testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc); + @Test void oldManualDeadlineRemovalKeepsSingleFlightAndUnknownOutcomeProtection(){ + testBusiness(5,1);clock.advance(2);cfg.setExclusiveGrantExpiresAtUtc(null); + var c=reserve(Operation.WAREHOUSES,1,null,null); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_REQUEST_INFLIGHT"); + service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));clock.advance(31); + var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + assertThat(account.getApiPauseReason()).isEqualTo("REQUEST_OUTCOME_UNKNOWN");assertThat(budget().pending.abandoned).isTrue(); + assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isZero(); + } + @Test void existingManualBusinessContinuesPastBothRecordedDeadlineAndGrantWithoutReset(){ + testBusiness(5,10);cfg.setExclusiveGrantExpiresAtUtc(now().plusSeconds(10));cfg.setContinuousBusinessCollectionEnabled(false); + task.setRequestKey("manual:2026-10-06");task.setRequestedBy("operator");task.setRoutingManifestJson("{\"nextPage\":37,\"scan\":2}"); + var original=businessBudget();original.http=8820;original.confirmedHttp=8819;task.setBusinessBudgetJson(SelfopSupport.json(original)); + var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(budget().pending.expiresAtUtc).isAfter(original.deadlineAtUtc); clock.advance(9);var permit=service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); - assertThat(permit.expiresAt()).isEqualTo(businessBudget().deadlineAtUtc.toInstant(ZoneOffset.UTC)); + assertThat(permit.expiresAt()).isEqualTo(clock.instant().plusSeconds(30)); clock.advance(2);service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); - assertThat(businessBudget().confirmedHttp).isEqualTo(1); - assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_DEADLINE"); - assertThat(businessBudget().stopReason).isEqualTo("BUSINESS_SESSION_DEADLINE"); + clock.advance(5);var restarted=new SelfopEgressService(mapper,cfg,clock);success(restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL),Operation.WAREHOUSES); + assertThat(businessBudget().http).isEqualTo(8822);assertThat(businessBudget().confirmedHttp).isEqualTo(8821); + assertThat(businessBudget().startedAtUtc).isEqualTo(original.startedAtUtc);assertThat(businessBudget().deadlineAtUtc).isEqualTo(original.deadlineAtUtc); + assertThat(businessBudget().continuous).isFalse();assertThat(businessBudget().stopReason).isNull(); + assertThat(task.getId()).isEqualTo(1L);assertThat(task.getBusinessRootTaskId()).isEqualTo(1L);assertThat(task.getRequestKey()).isEqualTo("manual:2026-10-06"); + assertThat(task.getRequestedBy()).isEqualTo("operator");assertThat(task.getRoutingManifestJson()).isEqualTo("{\"nextPage\":37,\"scan\":2}"); + verify(mapper,never()).terminateBusinessTasks(any(),any()); } - @Test void testBusinessExpiredOrStoppedPreparedAttemptCannotAuthorize(){ - testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);clock.advance(10); + @Test void businessShortPendingExpiryStillRejectsAfterSessionDeadlineRemoved(){ + testBusiness(5,10);cfg.setEgressLeaseSeconds(20);var c=reserve(Operation.WAREHOUSES,1,null,null);clock.advance(20); assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))) - .hasMessage("BUSINESS_SESSION_DEADLINE").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isFalse()); + .hasMessage("EGRESS_PERMIT_REPLAY_OR_EXPIRED").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isFalse()); assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isZero(); - assertThat(task.getStatus()).isEqualTo("DEAD"); + assertThat(task.getStatus()).isEqualTo("RUNNING"); } @Test void testBusinessMissingBudgetOrWrongRootFailsClosedWithoutBackfill(){ testBusiness(5,300);task.setBusinessBudgetJson(null); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().reservedHttp).isZero(); - task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),300));task.setBusinessRootTaskId(999L); + task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now()));task.setBusinessRootTaskId(999L); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); assertThat(businessBudget().http).isZero(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java index 760009638..a0753b047 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java @@ -403,7 +403,7 @@ class SelfopTaskServiceTest { service.create(root); var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); assertThat(root.getBusinessRootTaskId()).isEqualTo(root.getId());assertThat(budget.maxHttp).isZero(); - assertThat(budget.deadlineAtUtc).isEqualTo(budget.startedAtUtc.plusSeconds(91)); + assertThat(budget.deadlineAtUtc).isNull();assertThat(budget.continuous).isFalse(); var child=task("READY");child.setId(42L);child.setBusinessRootTaskId(root.getId()); assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(root.getId()); config.setEnvironment("production"); @@ -431,19 +431,29 @@ class SelfopTaskServiceTest { assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7); verify(runs).insert(next); } - @Test void expiredFetchCannotBeginAndManualRetryCannotRestartItsSession() { + @Test void existingManualDiscoveryPastDeadlineBeginsFromPersistedCheckpointAndIdentity() { var root=boundedRoot(2,utcNow().minusSeconds(1)); - assertThatThrownBy(()->service.begin(1L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); - assertThatThrownBy(()->service.retry(1L,"new-key","operator","retry")) - .isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); - verify(runs,never()).insert(any(SelfopSyncRun.class));verify(tasks,never()).insert(any(SelfopSyncTask.class)); - assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(2); + root.setTaskRole("ACCOUNT_DISCOVERY");root.setScopeId(null);root.setWarehouseCode(null);root.setDiscoveryScopeId(99L); + root.setRequestKey("manual:2026-10-06");root.setTaskType("BACKFILL");root.setAttemptCount(3); + root.setQueryFromUtc(LocalDateTime.of(2026,10,5,16,0));root.setQueryUntilUtc(LocalDateTime.of(2026,10,6,16,0)); + var manifest=new SelfopRoutingManifest();manifest.setNextPage(37);manifest.setScan(2);manifest.setPages(100);manifest.setTotal(1000L); + root.setRoutingManifestJson(json(manifest));String checkpoint=root.getRoutingManifestJson(),budget=root.getBusinessBudgetJson(); + var account=new SelfopOutboundDiscoveryScope();account.setId(99L);when(locks.lockDiscovery(99L)).thenReturn(account); + var next=service.begin(1L,new SelfopLeaseService.Lease(99L,"owner",7L,true)); + assertThat(next.getAttemptNo()).isEqualTo(4);assertThat(next.getTaskId()).isEqualTo(1L);assertThat(next.getStatus()).isEqualTo("FETCHING"); + assertThat(root.getRoutingManifestJson()).isEqualTo(checkpoint);assertThat(root.getBusinessBudgetJson()).isEqualTo(budget); + assertThat(root.getBusinessRootTaskId()).isEqualTo(1L);assertThat(root.getRequestKey()).isEqualTo("manual:2026-10-06"); + assertThat(root.getRequestedBy()).isEqualTo("operator");assertThat(root.getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,5,16,0)); + verify(runs).insert(next);verify(tasks,never()).insert(any(SelfopSyncTask.class)); } - @Test void expiredYieldAndBudgetWaitEndInsteadOfSchedulingAnotherDispatch() { - boundedRoot(2,utcNow().minusSeconds(1)); - assertThatThrownBy(()->service.yield(1L,20L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); - assertThatThrownBy(()->service.waitForBudget(1L,20L,utcNow().plusDays(1),lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); - verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class)); + @Test void pastRecordedDeadlineYieldAndBudgetWaitKeepSchedulingSameRoot() { + var root=boundedRoot(2,utcNow().minusSeconds(1));root.setStatus("RUNNING"); + var active=run("FETCHING");when(locks.lockRun(20L)).thenReturn(active);String budget=root.getBusinessBudgetJson(); + service.yield(1L,20L,lease);assertThat(root.getStatus()).isEqualTo("RETRY_WAIT");assertThat(active.getStatus()).isEqualTo("YIELDED"); + root.setStatus("RUNNING");active.setStatus("FETCHING");var until=utcNow().plusMinutes(5); + service.waitForBudget(1L,20L,until,lease); + assertThat(root.getNextRetryAtUtc()).isEqualTo(until);assertThat(root.getBusinessBudgetJson()).isEqualTo(budget); + verify(tasks,never()).update(any(),any()); } @Test void continuousFailureStopsInsteadOfBecomingAnotherScheduledRetry(){ var root=boundedRoot(0,utcNow().plusMinutes(3));config.setContinuousBusinessCollectionEnabled(true); @@ -467,7 +477,7 @@ class SelfopTaskServiceTest { assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).stopReason).isEqualTo("BUSINESS_SESSION_HTTP_LIMIT"); } @Test void manualRetryAuditRetainsOriginalRootAndCannotFreezeAnotherAllowance() { - var root=boundedRoot(3,utcNow().plusMinutes(3));root.setStatus("RETRY_WAIT");String frozen=root.getBusinessBudgetJson(); + var root=boundedRoot(3,utcNow().minusSeconds(1));root.setStatus("RETRY_WAIT");String frozen=root.getBusinessBudgetJson(); config.setRequestBudget(100);config.setTimeBudgetSeconds(900); assertThat(service.retry(1L,"retry-bounded","operator","retry failed attempt")).isSameAs(root); var saved=org.mockito.ArgumentCaptor.forClass(SelfopSyncTask.class);verify(tasks).insert(saved.capture()); @@ -489,8 +499,10 @@ class SelfopTaskServiceTest { order.verify(locks,times(2)).lockTask(1L); } - @Test void incompleteManifestCannotUseLocalWorkExceptionAfterDeadline() { + @Test void incompleteManifestNeverBypassesExplicitFailureStop() { var root=boundedRoot(1,utcNow().minusSeconds(1));when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND")); + var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); + budget.stopReason="AUTHORIZATION_REJECTED";root.setBusinessBudgetJson(json(budget)); for(String manifest:List.of("{\"directoryComplete\":true}","{\"directoryComplete\":true,\"objects\":[]}", "{\"directoryComplete\":true,\"objects\":{\"one\":{\"effectiveStageId\":\" \"}}}")) { root.setRoutingManifestJson(manifest); From 54f79b39029094bc7d2c007febbbcc3e08232132 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 22:23:48 +0800 Subject: [PATCH 58/80] feat(self-operated): expose private runtime configuration snapshots --- .../LingxingEgressRuntimeController.java | 32 ++++++++++ .../LingxingEgressRuntimeSnapshot.java | 9 +++ .../provider/lingxing/LingxingEgressGate.java | 25 +++++++- .../resources/openapi/lingxing-wms-v1.yaml | 53 ++++++++++++++-- .../LingxingEgressRuntimeControllerTest.java | 48 +++++++++++++++ .../LingxingEgressRuntimeSnapshotTest.java | 57 ++++++++++++++++++ .../request/SelfopEgressController.java | 1 + .../analytics/request/SelfopEgressDtos.java | 5 ++ .../request/SelfopEgressService.java | 20 ++++++- .../self-operated-analytics-egress-v1.yaml | 60 ++++++++++++++++++- .../request/SelfopEgressControllerTest.java | 57 ++++++++++++++++++ 11 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java new file mode 100644 index 000000000..13a329a75 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; +import com.baiyue.integration.provider.lingxing.LingxingEgressGate; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** Internal token is checked before the active gate is inspected. Never calls the provider. */ +@RestController +public class LingxingEgressRuntimeController { + private final InternalServiceTokenGuard guard; + private final LingxingEgressGate gate; + private final LingxingWmsProperties provider; + + public LingxingEgressRuntimeController(InternalServiceTokenGuard guard, LingxingEgressGate gate, + LingxingWmsProperties provider) { + this.guard = guard; this.gate = gate; this.provider = provider; + } + + @Operation(summary = "读取实际领星出口绑定配置的脱敏快照,无供应商或许可副作用") + @GetMapping("/internal/integrations/lingxing/wms/egress/runtime") + public ApiResponse runtime( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token) { + guard.verify(token); + return ApiResponse.success(gate.runtimeSnapshot(provider.enabled())); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java new file mode 100644 index 000000000..521478ed9 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.dto.lingxing; + +import java.time.Instant; + +/** Bound runtime configuration only. No credentials, addresses or execution attestation. */ +public record LingxingEgressRuntimeSnapshot(int schemaVersion, String service, Instant observedAt, + String mode, String environment, boolean continuousEnabled, String sourceAccountFingerprint, + boolean grantPresent, String grantFingerprint, long intervalMs, int permitMaxSeconds, + boolean providerEnabled, String budgetAuthorityFingerprint) { } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java index df05dea51..05a4e7393 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java @@ -8,12 +8,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Clock; import java.time.Instant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; /** Local serialization supplements the exclusive grant and durable account protection. */ @Component public final class LingxingEgressGate { + private static final int PERMIT_MAX_SECONDS = 30; private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class); private final LingxingEgressProperties properties; private final LingxingBudgetAuthority authority; @@ -30,6 +36,23 @@ public final class LingxingEgressGate { LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) { this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter; } + /** The very properties and clock used by execute; no semaphore, authority, budget or HTTP work. */ + public LingxingEgressRuntimeSnapshot runtimeSnapshot(boolean providerEnabled) { + String grant = properties.externalExclusiveGrantId(); + return new LingxingEgressRuntimeSnapshot(1, "integration", clock.instant(), + properties.collectionMode().name(), properties.environmentLabel(), + properties.continuousBusinessCollectionEnabled(), fingerprint("source-account", properties.sourceAccount()), + grant != null && !grant.isBlank(), fingerprint("grant", grant), properties.minStartInterval().toMillis(), + PERMIT_MAX_SECONDS, providerEnabled, fingerprint("budget-authority", properties.budgetAuthorityBaseUri() == null ? null + : properties.budgetAuthorityBaseUri().toString())); + } + private static String fingerprint(String domain, String value) { + if (value == null || value.isBlank()) return null; + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA256_UNAVAILABLE", impossible); } + } public T execute(EgressContext context, Operation operation, String hash, AuthorizedCall call) { try { return guarded(context, operation, hash, call); } catch (LingxingWmsException error) { @@ -61,7 +84,7 @@ public final class LingxingEgressGate { if (permit == null || !context.equals(permit.context()) || operation != permit.operation() || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() || !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) - || permit.expiresAt().isAfter(clock.instant().plusSeconds(30)) + || permit.expiresAt().isAfter(clock.instant().plusSeconds(PERMIT_MAX_SECONDS)) || (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) { recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID"); } diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml index 3c1a15a9b..e52a65bcb 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -6,16 +6,17 @@ info: Single-page or single-order adapter. No scheduling, business persistence or public Gateway route. Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone. Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive - account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled manual commands and their legitimate derived work; the adapter does not schedule work. All five endpoints apply the same gate. + account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled commands and their legitimate derived work; the adapter does not schedule work. Provider-query endpoints apply the same gate. Missing exclusive proof, persistent account budget authorization or control context fails closed before egress. - At most one account request is in flight and HTTP request starts are separated by at least 5 seconds. + At most one account request is in flight; request starts obey the bound minStartInterval (VALIDATION at least 5 seconds). A JVM lock is only local protection and is not proof of cross-environment account exclusivity. Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries; 429 is never retried, immediately pauses the account and returns persistent cooldown metadata. VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence. - External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil; - the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is - recoverable only within the original valid exclusive window, after both the persistently returned + External server configuration supplies externalExclusiveGrantId and a VALIDATION externalExclusiveUntil; + the nonempty grant must match context.exclusiveGrantId. BUSINESS has no grant, session or root time cutoff; + explicit stops, persistent account protections and each short single-attempt permit remain enforced. + A BUSINESS rate-limit pause is recoverable after both the persistently returned cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state. Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost @@ -32,7 +33,7 @@ x-lastleg-egress-coordination: requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization is refused; arbitrary client-created attempts are refused. complete verifies the same bindings. A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true - and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil, + and budgetConsumed=true, expires after now but no later than now plus 30 seconds (also within externalExclusiveUntil for VALIDATION), and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress; @@ -50,6 +51,26 @@ x-lastleg-egress-coordination: security: - InternalServiceToken: [] paths: + /internal/integrations/lingxing/wms/egress/runtime: + get: + operationId: lingxingEgressRuntime + summary: Read sanitized configuration from the active egress gate + description: >- + Internal token authentication only. No provider, database, budget-authority, permit or persistence side effect. + Returns actual bound configuration, not a verified claim or a commit identity. Fingerprints use lowercase + SHA-256 of UTF-8 "selfop-runtime:v1:\n" plus the exact original nonblank value, without trimming. + Domains are source-account, grant and budget-authority. Missing/blank values yield null, never an empty-value hash. + security: [{InternalServiceToken: []}] + responses: + '200': + description: ApiResponse code 200 with runtime snapshot, or A0401 for missing/invalid internal token + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/EgressRuntimeSnapshot'} /internal/integrations/lingxing/wms/warehouses/query: post: summary: Query one page of normal warehouses @@ -154,6 +175,26 @@ components: in: header name: X-Internal-Service-Token schemas: + EgressRuntimeSnapshot: + type: object + additionalProperties: false + required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, grantPresent, intervalMs, permitMaxSeconds, providerEnabled] + properties: + schemaVersion: {type: integer, enum: [1]} + service: {type: string, enum: [integration]} + observedAt: {type: string, format: date-time} + mode: {type: string, enum: [OFF, VALIDATION, BUSINESS]} + environment: {type: string} + continuousEnabled: {type: boolean} + sourceAccountFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} + grantPresent: {type: boolean} + grantFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} + intervalMs: + oneOf: [{type: integer, format: int64}, {type: string, pattern: '^[0-9]+$'}] + description: Actual bound interval; shared long-value JSON serialization may encode it as a decimal string. + permitMaxSeconds: {type: integer, enum: [30]} + providerEnabled: {type: boolean} + budgetAuthorityFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} EgressContext: type: object additionalProperties: false diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java new file mode 100644 index 000000000..d6c608107 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java @@ -0,0 +1,48 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; +import com.baiyue.integration.provider.lingxing.LingxingEgressGate; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class LingxingEgressRuntimeControllerTest { + @Test void internalTokenIsRequiredBeforeGateInspectionAndPublicPathDoesNotExist() throws Exception { + String token = UUID.randomUUID().toString();var guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token);ReflectionTestUtils.invokeMethod(guard, "initializeToken"); + var gate = mock(LingxingEgressGate.class);var provider = new LingxingWmsProperties(false,null,null,null,null,null); + var converter = new MappingJackson2HttpMessageConverter(Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build()); + var mvc = MockMvcBuilders.standaloneSetup(new LingxingEgressRuntimeController(guard,gate,provider)) + .setMessageConverters(converter).setControllerAdvice(new GlobalExceptionHandler()).build(); + String path = "/internal/integrations/lingxing/wms/egress/runtime"; + for (String bad : new String[] {"", "wrong", "Bearer user-jwt"}) { + mvc.perform(get(path).header("X-Internal-Service-Token",bad)).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + } + verifyNoInteractions(gate); + mvc.perform(get("/api/integrations/lingxing/wms/egress/runtime")).andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(org.hamcrest.Matchers.not("200"))) + .andExpect(jsonPath("$.data.service").doesNotExist()); + verifyNoInteractions(gate); + var snapshot = new LingxingEgressRuntimeSnapshot(1,"integration",Instant.parse("2026-10-13T14:00:00Z"), + "OFF","test",false,null,false,null,5000,30,false,null); + when(gate.runtimeSnapshot(false)).thenReturn(snapshot); + mvc.perform(get(path).header("X-Internal-Service-Token",token)).andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")).andExpect(jsonPath("$.data.service").value("integration")) + .andExpect(jsonPath("$.data.observedAt").value("2026-10-13T14:00:00Z")) + .andExpect(jsonPath("$.data.mode").value("OFF")).andExpect(jsonPath("$.data.providerEnabled").value(false)) + .andExpect(jsonPath("$.data.verified").doesNotExist()).andExpect(jsonPath("$.data.grant").doesNotExist()); + verify(gate).runtimeSnapshot(false);verifyNoMoreInteractions(gate); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java new file mode 100644 index 000000000..a87b6342f --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java @@ -0,0 +1,57 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LingxingEgressRuntimeSnapshotTest { + private static final Instant NOW = Instant.parse("2026-10-13T14:00:00Z"); + private final LingxingBudgetAuthority authority = mock(LingxingBudgetAuthority.class); + private LingxingEgressGate gate(CollectionMode mode, String account, String grant, URI uri) { + return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", account, grant, null, uri, + mode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1200), null, true), + authority, Clock.fixed(NOW, ZoneOffset.UTC), millis -> { throw new AssertionError("DIAGNOSTIC_MUST_NOT_WAIT"); }); + } + private static String hash(String domain, String value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8))); + } + @Test void sameGateReturnsActualBindingsWithDomainSeparatedExactRawFingerprints() throws Exception { + var snapshot = gate(CollectionMode.BUSINESS, "same-raw-value", "same-raw-value", URI.create("http://authority:9303")).runtimeSnapshot(true); + assertThat(snapshot.schemaVersion()).isEqualTo(1);assertThat(snapshot.service()).isEqualTo("integration"); + assertThat(snapshot.observedAt()).isEqualTo(NOW);assertThat(snapshot.mode()).isEqualTo("BUSINESS"); + assertThat(snapshot.environment()).isEqualTo("test");assertThat(snapshot.continuousEnabled()).isTrue(); + assertThat(snapshot.providerEnabled()).isTrue();assertThat(snapshot.grantPresent()).isTrue(); + assertThat(snapshot.intervalMs()).isEqualTo(1200);assertThat(snapshot.permitMaxSeconds()).isEqualTo(30); + assertThat(snapshot.sourceAccountFingerprint()).isEqualTo(hash("source-account", "same-raw-value")); + assertThat(snapshot.grantFingerprint()).isEqualTo(hash("grant", "same-raw-value")).isNotEqualTo(snapshot.sourceAccountFingerprint()); + assertThat(snapshot.budgetAuthorityFingerprint()).isEqualTo(hash("budget-authority", "http://authority:9303")); + assertThat(gate(CollectionMode.BUSINESS, " raw ", " raw ", null).runtimeSnapshot(false).grantFingerprint()) + .isEqualTo(hash("grant", " raw ")).isNotEqualTo(hash("grant", "raw")); + verifyNoInteractions(authority); + } + @Test void offAndValidationAreObservableAndAbsentOrBlankValuesAreNotHashed() { + for (String value : new String[] {null, "", " "}) { + var s = gate(CollectionMode.OFF, value, value, null).runtimeSnapshot(false); + assertThat(s.mode()).isEqualTo("OFF");assertThat(s.sourceAccountFingerprint()).isNull(); + assertThat(s.grantPresent()).isFalse();assertThat(s.grantFingerprint()).isNull(); + assertThat(s.budgetAuthorityFingerprint()).isNull();assertThat(s.providerEnabled()).isFalse(); + } + assertThat(gate(CollectionMode.VALIDATION, null, null, null).runtimeSnapshot(false).intervalMs()).isEqualTo(5000); + verifyNoInteractions(authority); + } + @Test void serializedSnapshotContainsNoRawIdentityAddressCredentialOrVerifiedClaim() throws Exception { + var raw = new ObjectMapper().findAndRegisterModules().writeValueAsString( + gate(CollectionMode.BUSINESS, "private-account", "private-grant", URI.create("http://private-host:9303")).runtimeSnapshot(true)); + assertThat(raw).doesNotContain("private-account", "private-grant", "private-host", "http://", "verified", "appSecret", "internalServiceToken"); + verifyNoInteractions(authority); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java index 591fd1485..0a193dcb7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*; public class SelfopEgressController { private final InternalServiceTokenGuard guard;private final SelfopEgressService service; public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;} + @GetMapping("/runtime") public ApiResponse runtime(@RequestHeader(value="X-Internal-Service-Token",required=false) String token){guard.verify(token);return ApiResponse.success(service.runtime());} @PostMapping("/authorize") public ApiResponse authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}} @PostMapping("/complete") public ApiResponse complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java index 438f90d82..a48931710 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java @@ -2,6 +2,11 @@ package com.baiyue.lastleg.selfoperated.analytics.request; import java.time.Instant; public final class SelfopEgressDtos { private SelfopEgressDtos(){} + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) + public record RuntimeSnapshot(int schemaVersion,String service,Instant observedAt,String mode,String environment, + boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint, + long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds, + boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){} public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY} public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){} @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index ad6016d53..955a10473 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -9,10 +9,26 @@ import java.time.*; import java.util.*; @Service public class SelfopEgressService { + private static final int PERMIT_MAX_SECONDS=30,PENDING_MAX_SECONDS=120; private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock; @Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());} SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;} private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);} + /** Reads the same configuration instance used by authorization; no account, permit or provider access. */ + public RuntimeSnapshot runtime(){ + return new RuntimeSnapshot(1,"lastleg",clock.instant(),config.getCollectionMode(),config.getEnvironment(), + config.isContinuousBusinessCollectionEnabled(),fingerprint("source-account",config.getSourceAccount()), + config.getExclusiveGrantId()!=null&&!config.getExclusiveGrantId().isBlank(),fingerprint("grant",config.getExclusiveGrantId()), + config.getBusinessMinRequestIntervalMs(),PERMIT_MAX_SECONDS,config.isExecutionEnabled(),pendingMaxSeconds(), + config.isExclusiveHandoffVerified(),config.isPriorEgressClosed(),config.isInflightDrained(),config.isTransferredBudgetVerified()); + } + private int pendingMaxSeconds(){return Math.max(1,Math.min(PENDING_MAX_SECONDS,config.getEgressLeaseSeconds()));} + private static String fingerprint(String domain,String value){ + if(value==null||value.isBlank())return null; + try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:"+domain+"\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException("SHA-256 unavailable",impossible);} + } @Transactional(noRollbackFor=EgressRejectedException.class) public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){ if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID"); @@ -41,7 +57,7 @@ public class SelfopEgressService { if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000)); if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); - var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); + var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(pendingMaxSeconds()); if("VALIDATION".equals(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now; @@ -66,7 +82,7 @@ public class SelfopEgressService { if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED"); if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}} if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID"); - pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(30);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget); + pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(PERMIT_MAX_SECONDS);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget); return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true); } @Transactional(noRollbackFor=EgressRejectedException.class) diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml index c3df64461..cfcd5e612 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml @@ -3,6 +3,30 @@ info: title: Self-operated Analytics Internal Egress Authority version: 1.0.0 paths: + /internal/lastleg/self-operated-analytics/egress/runtime: + get: + operationId: getSelfoperatedEgressRuntime + summary: 读取脱敏的实际出口配置快照 + description: >- + 仅内部服务令牌,不提供公网路由。直接读取 Egress 执行所用的同一配置实例, + 不读取数据库、不预留或消费许可、不调用供应商。响应不是部署验证结论, + 不包含 verified、镜像版本、原始账号、grant、Token 或 URL。 + intervalMs 为当前配置间隔,持久账户间隔可能更严格,须另行只读核验。 + pendingMaxSeconds 为实际配置经 1 至 120 秒裁剪后的上限;permitMaxSeconds 为单次许可上限, + VALIDATION 仍可能受验证截止及 grant 截止进一步缩短。 + security: [{ internalServiceToken: [] }] + responses: + '200': + description: ApiResponse 成功响应,data 为只读白名单快照 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/RuntimeSnapshot' } + '401': { description: 内部令牌无效 } + '503': { description: 内部令牌未配置 } /internal/lastleg/self-operated-analytics/egress/authorize: post: operationId: authorizeSelfoperatedEgressAttempt @@ -11,8 +35,10 @@ paths: 仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配; requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定, 后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1, - expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝, - BUSINESS 仅接受 production 或 test;TEST 任务入口仅允许受控人工任务及合法派生,不允许自动采集计划。独占交接证明及接续预算由服务端配置核验。 + expiresAt 最多 30 秒;VALIDATION 不超过验证根截止及 grant 截止。 + BUSINESS 不受人为会话或 grant 截止阻断,但保留短许可过期、身份、显式停止及账户保护。 + OFF 默认拒绝;BUSINESS 仅接受 production 或 test,人工根及合法派生保持原身份, + 连续计划根另须连续开关与持久化 planner 身份。独占交接证明及接续计数由服务端配置核验。 security: [{ internalServiceToken: [] }] requestBody: required: true @@ -64,6 +90,36 @@ components: securitySchemes: internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token } schemas: + RuntimeSnapshot: + type: object + additionalProperties: false + required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, sourceAccountFingerprint, grantPresent, grantFingerprint, intervalMs, permitMaxSeconds, executionEnabled, pendingMaxSeconds, exclusiveHandoffVerified, priorEgressClosed, inflightDrained, transferredBudgetVerified] + properties: + schemaVersion: { type: integer, enum: [1] } + service: { type: string, enum: [lastleg] } + observedAt: { type: string, format: date-time, description: UTC 观测时间 } + mode: { type: string, nullable: true, description: 原样返回实际配置,不将未知值转为 BUSINESS } + environment: { type: string, nullable: true } + continuousEnabled: { type: boolean } + sourceAccountFingerprint: + type: string + nullable: true + pattern: '^[a-f0-9]{64}$' + description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:source-account 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。' + grantPresent: { type: boolean, description: grant 原值非 null 且非 blank } + grantFingerprint: + type: string + nullable: true + pattern: '^[a-f0-9]{64}$' + description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:grant 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。' + intervalMs: { type: integer, format: int64, description: 实际配置的 BUSINESS 最小请求间隔毫秒数,未合并持久账户间隔 } + permitMaxSeconds: { type: integer, enum: [30] } + executionEnabled: { type: boolean } + pendingMaxSeconds: { type: integer, minimum: 1, maximum: 120 } + exclusiveHandoffVerified: { type: boolean } + priorEgressClosed: { type: boolean } + inflightDrained: { type: boolean } + transferredBudgetVerified: { type: boolean } EgressContext: type: object required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java index df4467a3f..c2ed86316 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java @@ -4,6 +4,63 @@ import org.junit.jupiter.api.Test; import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.*; class SelfopEgressControllerTest { + @Test void runtimeRequiresInternalTokenBeforeReadingConfiguration(){ + var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); + for(String token:new String[]{null,"","wrong"})assertThatThrownBy(()->c.runtime(token)) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class) + .satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401)); + verifyNoInteractions(service); + var missing=new SelfopEgressController(new InternalServiceTokenGuard(""),service); + assertThatThrownBy(()->missing.runtime("synthetic")).isInstanceOf(org.springframework.web.server.ResponseStatusException.class) + .satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(503)); + verifyNoInteractions(service);c.runtime("synthetic");verify(service).runtime(); + } + @Test void runtimeUsesExecutionConfigurationAndSerializesOnlyWhitelistedFields() throws Exception { + var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties(); + config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true); + config.setSourceAccount("synthetic-private-account");config.setExclusiveGrantId("synthetic-private-grant"); + config.setInternalServiceToken("synthetic-private-token");config.setIntegrationBaseUrl("http://synthetic-private-host"); + config.setBusinessMinRequestIntervalMs(1200);config.setEgressLeaseSeconds(999); + config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true); + var now=java.time.Instant.parse("2026-10-13T00:00:00Z"); + var service=new SelfopEgressService(storage,config,java.time.Clock.fixed(now,java.time.ZoneOffset.UTC)); + var controller=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); + var response=controller.runtime("synthetic");var runtime=response.data(); + assertThat(runtime.schemaVersion()).isEqualTo(1);assertThat(runtime.service()).isEqualTo("lastleg");assertThat(runtime.observedAt()).isEqualTo(now); + assertThat(runtime.mode()).isEqualTo("BUSINESS");assertThat(runtime.environment()).isEqualTo("test");assertThat(runtime.continuousEnabled()).isTrue(); + assertThat(runtime.intervalMs()).isEqualTo(1200);assertThat(runtime.permitMaxSeconds()).isEqualTo(30);assertThat(runtime.pendingMaxSeconds()).isEqualTo(120); + assertThat(runtime.executionEnabled()).isTrue();assertThat(runtime.grantPresent()).isTrue(); + assertThat(runtime.exclusiveHandoffVerified()&&runtime.priorEgressClosed()&&runtime.inflightDrained()&&runtime.transferredBudgetVerified()).isTrue(); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + String serialized=mapper.writeValueAsString(response);var data=mapper.readTree(serialized).path("data"); + var names=new java.util.HashSet();data.fieldNames().forEachRemaining(names::add); + assertThat(names).containsExactlyInAnyOrder("schemaVersion","service","observedAt","mode","environment","continuousEnabled", + "sourceAccountFingerprint","grantPresent","grantFingerprint","intervalMs","permitMaxSeconds","executionEnabled","pendingMaxSeconds", + "exclusiveHandoffVerified","priorEgressClosed","inflightDrained","transferredBudgetVerified"); + assertThat(serialized).doesNotContain("synthetic-private-account","synthetic-private-grant","synthetic-private-token","synthetic-private-host"); + config.setCollectionMode("VALIDATION");config.setContinuousBusinessCollectionEnabled(false);config.setBusinessMinRequestIntervalMs(5000);config.setEgressLeaseSeconds(0); + var changed=controller.runtime("synthetic").data();assertThat(changed.mode()).isEqualTo("VALIDATION");assertThat(changed.continuousEnabled()).isFalse(); + assertThat(changed.intervalMs()).isEqualTo(5000);assertThat(changed.pendingMaxSeconds()).isEqualTo(1); + assertThat(runtime.mode()).isEqualTo("BUSINESS");verifyNoInteractions(storage); + } + @Test void runtimeFingerprintsUseRawUtf8WithSeparateDomainsAndExplicitNulls() throws Exception { + var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties(); + var service=new SelfopEgressService(storage,config); + String value=" fixture-账户 ";config.setSourceAccount(value);config.setExclusiveGrantId(value); + var digest=java.security.MessageDigest.getInstance("SHA-256");var hex=java.util.HexFormat.of(); + var runtime=service.runtime(); + assertThat(runtime.sourceAccountFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:source-account\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + assertThat(runtime.grantFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:grant\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + assertThat(runtime.sourceAccountFingerprint()).isNotEqualTo(runtime.grantFingerprint()); + config.setExclusiveGrantId(value.trim());assertThat(service.runtime().grantFingerprint()).isNotEqualTo(runtime.grantFingerprint()); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + for(String empty:new String[]{null,""," \t"}){ + config.setSourceAccount(empty);config.setExclusiveGrantId(empty);var absent=service.runtime(); + assertThat(absent.grantPresent()).isFalse();assertThat(absent.grantFingerprint()).isNull();assertThat(absent.sourceAccountFingerprint()).isNull(); + var node=mapper.valueToTree(absent);assertThat(node.has("grantFingerprint")).isTrue();assertThat(node.path("grantFingerprint").isNull()).isTrue(); + } + verifyNoInteractions(storage); + } @Test void invalidInternalTokenCannotReachAuthority(){ var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401)); From 3d3507a770cc067209cfb779840a1a01ae5b87af Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 23:40:44 +0800 Subject: [PATCH 59/80] feat(selfop): add bounded independent quantity diagnostic capture --- .../LingxingQuantityCaptureProperties.java | 19 ++ .../lingxing/LingxingQuantityCapture.java | 171 ++++++++++++++++++ .../lingxing/LingxingQuantityEvidence.java | 34 ++++ .../lingxing/LingxingQuantityHttpSink.java | 57 ++++++ .../provider/lingxing/LingxingWmsClient.java | 15 +- .../lingxing/LingxingQuantityCaptureTest.java | 146 +++++++++++++++ .../LingxingQuantityHttpSinkTest.java | 65 +++++++ .../lingxing/LingxingWmsClientTest.java | 18 ++ .../QuantityDiagnosticConfiguration.java | 8 + .../QuantityDiagnosticContract.java | 22 +++ .../QuantityDiagnosticController.java | 52 ++++++ .../diagnostic/QuantityDiagnosticPolicy.java | 89 +++++++++ .../QuantityDiagnosticProperties.java | 11 ++ .../diagnostic/QuantityDiagnosticService.java | 125 +++++++++++++ .../db/selfop-quantity-diagnostics.sql | 38 ++++ ...lfop-quantity-diagnostics-internal-v1.yaml | 105 +++++++++++ .../QuantityDiagnosticControllerTest.java | 47 +++++ .../QuantityDiagnosticServiceTest.java | 169 +++++++++++++++++ 18 files changed, 1190 insertions(+), 1 deletion(-) create mode 100644 integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java create mode 100644 lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql create mode 100644 lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java new file mode 100644 index 000000000..1e94414c2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java @@ -0,0 +1,19 @@ +package com.baiyue.integration.config; + +import java.time.Instant; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** Fixed one-shot scope. No refresh/restart may extend the configured absolute window. */ +@ConfigurationProperties("baiyue.integration.lingxing-wms.quantity-capture") +public record LingxingQuantityCaptureProperties(@DefaultValue("false") boolean enabled, + String captureId, String sourceAccount, String rootTaskId, String sessionId, + String warehouseCode, String customerCode, Instant startsAt, Instant expiresAt) { + public boolean valid() { + return text(captureId) && text(sourceAccount) && text(rootTaskId) && text(sessionId) + && text(warehouseCode) && text(customerCode) && startsAt != null && expiresAt != null + && expiresAt.isAfter(startsAt) && !expiresAt.isAfter(startsAt.plusSeconds(1800)); + } + private static boolean text(String value) { return value != null && !value.isBlank() && value.length() <= 128; } + @Override public String toString() { return "LingxingQuantityCaptureProperties[enabled=" + enabled + "]"; } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java new file mode 100644 index 000000000..8500b810c --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java @@ -0,0 +1,171 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingQuantityCaptureProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.EgressContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; + +/** Passive bounded observer. It has no supplier client and never changes collection decisions. */ +@Component +public final class LingxingQuantityCapture { + private static final Logger LOG = LoggerFactory.getLogger(LingxingQuantityCapture.class); + private static final int MAX_ORDERS = 100, MAX_ROWS = 2000, MAX_BYTES = 2 * 1024 * 1024; + private final LingxingQuantityCaptureProperties config; + private final ObjectMapper mapper; + private final Sink sink; + private final Clock clock; + private final ThreadPoolExecutor worker; + private final Map pending = new HashMap<>(); + private final Set completed = new HashSet<>(); + private volatile String stopReason; + private int records, rows, bytes; + + @Autowired + public LingxingQuantityCapture(LingxingQuantityCaptureProperties config, ObjectMapper mapper, + ObjectProvider sink) { + this(config, mapper, sink.getIfAvailable(), Clock.systemUTC()); + } + LingxingQuantityCapture(LingxingQuantityCaptureProperties config, ObjectMapper mapper, Sink sink, Clock clock) { + this.config = config; this.mapper = mapper; this.sink = sink; this.clock = clock; + worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(MAX_ORDERS), task -> { + Thread thread = new Thread(task, "lingxing-quantity-capture"); thread.setDaemon(true); return thread; + }, new ThreadPoolExecutor.AbortPolicy()); + if (!config.enabled()) stopReason = "DISABLED"; + else if (!config.valid() || sink == null) stopReason = "CONFIGURATION_UNAVAILABLE"; + } + + /** Called only after the normal projection has successfully validated a supplier response. */ + public synchronized void observe(JsonNode data, boolean detail, EgressContext context, Instant observedAt) { + try { + if (!active() || context == null || !context.wellFormed() + || !Objects.equals(config.sourceAccount(), context.sourceAccount()) + || !Objects.equals(config.rootTaskId(), context.rootTaskId()) + || !Objects.equals(config.sessionId(), context.sessionId())) return; + if (detail) enqueue(data, true, context, observedAt); + else for (JsonNode row : data.path("records")) { + if (!active()) break; + enqueue(row, false, context, observedAt); + } + } catch (RuntimeException error) { stop("OBSERVATION_FAILED"); } + } + private void enqueue(JsonNode data, boolean detail, EgressContext context, Instant time) { + String warehouse = text(data, "whCode", 64), customer = text(data, "customerCode", 64); + if (!("*".equals(config.warehouseCode()) || config.warehouseCode().equals(warehouse)) + || !("*".equals(config.customerCode()) || config.customerCode().equals(customer))) return; + Key key = new Key(warehouse, customer, text(data, "deliveryNo", 128)); + if (completed.contains(key)) return; + // Freeze the first 100 list candidates. Extra page rows and unselected details cost no retained memory. + if (detail && !pending.containsKey(key)) return; + if (!detail && (pending.containsKey(key) || pending.size() + completed.size() >= MAX_ORDERS)) return; + JsonNode items = data.path(detail ? "skuVOList" : "productList"); + if (!items.isArray() || items.isEmpty() || items.size() > 200) { stop("SKU_RECORD_LIMIT_OR_MISSING"); return; } + Map quantities = new TreeMap<>(); + for (JsonNode item : items) { + String sku = text(item, "productSku", 128); + Quantities next = new Quantities(detail ? null : quantity(item.get("qty")), + detail ? quantity(item.get("deliveryQty")) : null, + detail ? quantity(item.get("actualDeliveryQty")) : null); + quantities.merge(sku, next, Quantities::add); + } + Observation observation = new Observation(data.path("status").isInt() ? data.path("status").asText() : null, + optionalText(data, "outboundTime", 64), time, context.attemptId(), true); + Sample sample = new Sample(key, detail, observation, quantities, items.size()); + process(sample); + } + private void process(Sample sample) { + try { + if (!active()) return; + if (++records > 2000 || (rows += sample.rowCount()) > MAX_ROWS + || (bytes += mapper.writeValueAsBytes(sample).length) > MAX_BYTES) { stop("CAPTURE_LIMIT"); return; } + if (completed.contains(sample.key())) return; + // Directory and detail stages can be far apart: candidates live until the fixed session expiry. + Pending pair = pending.computeIfAbsent(sample.key(), ignored -> new Pending()); + // Keep the first observation on each side; retries cannot silently replace its generation. + if (sample.detail()) { if (pair.detail == null) pair.detail = sample; } + else if (pair.list == null) pair.list = sample; + if (pair.list == null || pair.detail == null) return; + Set skus = new TreeSet<>(pair.list.quantities().keySet()); skus.addAll(pair.detail.quantities().keySet()); + if (skus.size() > 200) { stop("ORDER_SKU_LIMIT"); return; } + List lines = new ArrayList<>(); boolean difference = false; + for (String sku : skus) { + Quantities list = pair.list.quantities().get(sku), detail = pair.detail.quantities().get(sku); + BigDecimal forecast = detail == null ? null : detail.delivery, actual = detail == null ? null : detail.actual; + difference |= forecast != null && actual != null && forecast.compareTo(actual) != 0; + lines.add(new Line(sku, list != null, detail != null, list == null ? null : list.list, forecast, actual)); + } + Key key = sample.key(); + LingxingQuantityEvidence evidence = new LingxingQuantityEvidence(config.captureId(), config.sourceAccount(), + config.rootTaskId(), config.sessionId(), key.warehouse(), key.customer(), key.order(), + pair.list.observation(), pair.detail.observation(), List.copyOf(lines)); + worker.execute(() -> { + try { + if (!clock.instant().isBefore(config.expiresAt())) { abortTransmission("EXPIRED"); return; } + sink.accept(evidence); + } catch (ReceiverStopped normalStop) { abortTransmission(normalStop.reason()); } + catch (Exception failure) { abortTransmission("DIAGNOSTIC_FAILED"); } + }); + completed.add(key); pending.remove(key); + if (difference) stop("FORECAST_ACTUAL_DIFFERENCE"); + else if (completed.size() >= MAX_ORDERS) stop("ORDER_LIMIT"); + } catch (Exception error) { stop("DIAGNOSTIC_FAILED"); } + } + private boolean active() { + if (stopReason != null) return false; + if (!clock.instant().isBefore(config.expiresAt())) { stop("EXPIRED"); return false; } + return !clock.instant().isBefore(config.startsAt()); + } + private synchronized void stop(String reason) { + if (stopReason != null) return; + stopReason = reason; + if (!"ORDER_LIMIT".equals(reason) && !"FORECAST_ACTUAL_DIFFERENCE".equals(reason)) worker.getQueue().clear(); + pending.clear(); + LOG.info("Lingxing quantity capture stopped: {}", reason); + } + private synchronized void abortTransmission(String reason) { + worker.getQueue().clear(); pending.clear(); + if (!reason.equals(stopReason)) { + stopReason = reason; LOG.info("Lingxing quantity capture stopped: {}", reason); + } + } + public String stopReason() { return stopReason; } + @PreDestroy public void close() { stop("SHUTDOWN"); worker.shutdownNow(); } + private static String text(JsonNode data, String field, int max) { + String value = optionalText(data, field, max); + if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing diagnostic identity"); + return value; + } + private static String optionalText(JsonNode data, String field, int max) { + JsonNode value = data.get(field); + if (value == null || value.isNull()) return null; + if (!value.isTextual() || value.textValue().length() > max) throw new IllegalArgumentException("Invalid diagnostic text"); + return value.textValue(); + } + private static BigDecimal quantity(JsonNode value) { + if (value == null || value.isNull() || value.isTextual() && value.textValue().isBlank()) return null; + if (!value.isNumber()) throw new IllegalArgumentException("Invalid diagnostic quantity"); + BigDecimal result = value.decimalValue(); + if (result.precision() > 30 || Math.abs(result.scale()) > 10) throw new IllegalArgumentException("Quantity limit"); + return result; + } + private record Key(String warehouse, String customer, String order) { } + private record Quantities(BigDecimal list, BigDecimal delivery, BigDecimal actual) { + Quantities add(Quantities other) { return new Quantities(sum(list, other.list), sum(delivery, other.delivery), sum(actual, other.actual)); } + private static BigDecimal sum(BigDecimal a, BigDecimal b) { return a == null || b == null ? null : a.add(b); } + } + private record Sample(Key key, boolean detail, Observation observation, Map quantities, int rowCount) { } + private static final class Pending { + private Sample list, detail; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java new file mode 100644 index 000000000..0e871d723 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java @@ -0,0 +1,34 @@ +package com.baiyue.integration.provider.lingxing; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** Whitelist only; never includes source payload, addresses, labels or credentials. */ +public record LingxingQuantityEvidence(String diagnosticSessionId, String sourceAccount, String rootTaskId, + String collectionSessionId, String warehouseCode, String ownerCode, String orderNo, + Observation listObservation, Observation detailObservation, List skus) { + public record Observation(String status, String outboundTime, Instant observedAt, String attemptId, + boolean skuSetComplete) { } + public record Line(String sku, boolean listPresent, boolean detailPresent, BigDecimal listQty, + BigDecimal forecastQty, BigDecimal actualQty) { } + public boolean skuSetEqual() { return skus.stream().allMatch(line -> line.listPresent() && line.detailPresent()); } + public boolean forecastActualDifference() { return skus.stream().anyMatch(line -> line.forecastQty() != null + && line.actualQty() != null && line.forecastQty().compareTo(line.actualQty()) != 0); } + /** Receiver must durably enforce window, deduplication and cumulative bounds across restarts. */ + public interface Sink { void accept(LingxingQuantityEvidence evidence); } + /** Normal durable receiver decision, including an accepted final order; not a transport failure. */ + public static final class ReceiverStopped extends RuntimeException { + private final String reason; + private final boolean orderStored; + public ReceiverStopped(String reason, boolean orderStored) { + super("Diagnostic receiver stopped"); + if (reason == null || !reason.matches("[A-Z][A-Z0-9_]{0,63}")) { + throw new IllegalArgumentException("Invalid diagnostic stop reason"); + } + this.reason = reason; this.orderStored = orderStored; + } + public String reason() { return reason; } + public boolean orderStored() { return orderStored; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java new file mode 100644 index 000000000..0a5571b22 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java @@ -0,0 +1,57 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.net.URI; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Set; + +/** Async worker calls this once per paired order. No retry and no supplier requests. */ +@Component +public final class LingxingQuantityHttpSink implements LingxingQuantityEvidence.Sink { + private static final String PATH = "/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders"; + private final URI baseUri; + private final ObjectMapper mapper; + private final String token; + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + public LingxingQuantityHttpSink(LingxingEgressProperties config, ObjectMapper mapper, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.baseUri = config.budgetAuthorityBaseUri(); + this.mapper = mapper.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.token = token; + } + @Override public void accept(LingxingQuantityEvidence evidence) { + try { + if (baseUri == null || token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable(); + byte[] body = mapper.writeValueAsBytes(evidence); + if (body.length > 256 * 1024 || evidence.skus().size() > 200) throw unavailable(); + HttpRequest request = HttpRequest.newBuilder(baseUri.resolve(PATH)).timeout(Duration.ofSeconds(3)) + .header("X-Internal-Service-Token", token).header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200 || response.body().length() > 16384) throw unavailable(); + JsonNode root = mapper.readTree(response.body()); + if (root == null || !"200".equals(root.path("code").textValue())) throw unavailable(); + String outcome = root.path("data").path("outcome").asText(); + if (!Set.of("ACCEPTED", "DUPLICATE", "STOPPED").contains(outcome)) throw unavailable(); + // Receiver may accept the final order and atomically close its durable session. + JsonNode reason = root.path("data").path("stopReason"); + if (!reason.isMissingNode() && !reason.isNull()) { + if (!reason.isTextual() || !reason.textValue().matches("[A-Z][A-Z0-9_]{0,63}")) throw unavailable(); + throw new LingxingQuantityEvidence.ReceiverStopped(reason.textValue(), !"STOPPED".equals(outcome)); + } + if ("STOPPED".equals(outcome)) throw unavailable(); + } catch (LingxingQuantityEvidence.ReceiverStopped normalStop) { + throw normalStop; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw unavailable(); + } catch (Exception failed) { throw unavailable(); } + } + private static IllegalStateException unavailable() { return new IllegalStateException("DIAGNOSTIC_RECEIVER_UNAVAILABLE"); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java index fda1731da..33f3d4373 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -48,6 +48,12 @@ public final class LingxingWmsClient { private final HttpClient httpClient; private final Clock clock; private final LingxingEgressGate egressGate; + private LingxingQuantityCapture quantityCapture; + + @Autowired + void setQuantityCapture(LingxingQuantityCapture quantityCapture) { + this.quantityCapture = quantityCapture; + } @Autowired public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) { @@ -184,7 +190,14 @@ public final class LingxingWmsClient { } throw error; } - return projection.apply(json, handle.metadata()); + T result = projection.apply(json, handle.metadata()); + if (quantityCapture != null && (operation == Operation.OUTBOUND_QUERY + || operation == Operation.OUTBOUND_LOCATE || operation == Operation.OUTBOUND_DETAIL)) { + // Diagnostics must never alter the normal projection or the egress completion result. + try { quantityCapture.observe(json, operation == Operation.OUTBOUND_DETAIL, context, clock.instant()); } + catch (RuntimeException ignored) { /* Passive diagnostic failure is isolated. */ } + } + return result; }); } diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java new file mode 100644 index 000000000..7624cd4d5 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java @@ -0,0 +1,146 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingQuantityCaptureProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class LingxingQuantityCaptureTest { + private static final Instant NOW = Instant.parse("2026-10-13T01:00:00Z"); + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private final BlockingQueue saved = new LinkedBlockingQueue<>(); + private EgressContext context(String session) { return new EgressContext("account", CollectionMode.BUSINESS, + "root", session, "attempt", "grant"); } + private LingxingQuantityCapture capture(Instant expiry, LingxingQuantityEvidence.Sink sink) { + return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true, "capture", "account", + "root", "session", "*", "*", NOW.minusSeconds(1), expiry), mapper, sink, Clock.fixed(NOW, ZoneOffset.UTC)); + } + private void observe(LingxingQuantityCapture c, boolean detail, String items, String session, int status) throws Exception { + String order = "{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D\",\"status\":" + status + + ",\"" + (detail ? "skuVOList" : "productList") + "\":[" + items + "]}"; + c.observe(mapper.readTree(detail ? order : "{\"records\":[" + order + "]}"), detail, context(session), NOW); + } + @ParameterizedTest @ValueSource(ints = {10, 8}) + void preservesBothSemanticOutcomesAndStopsAtDifference(int listQty) throws Exception { + try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { + observe(c.value, false, "{\"productSku\":\"S\",\"qty\":" + listQty + "}", "session", 100); + observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":8}", "session", 100); + var e = saved.poll(3, TimeUnit.SECONDS); + assertThat(e).isNotNull(); assertThat(e.forecastActualDifference()).isTrue(); + assertThat(e.skus().get(0).listQty()).isEqualByComparingTo(Integer.toString(listQty)); + assertThat(e.skuSetEqual()).isTrue(); + awaitStop(c.value, "FORECAST_ACTUAL_DIFFERENCE"); + } + } + @Test void duplicateDecimalsUnknownsAndSkuMismatchRemainVisibleForCancelledOrder() throws Exception { + try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { + observe(c.value, false, "{\"productSku\":\"S\",\"qty\":0.1},{\"productSku\":\"S\",\"qty\":0.2}," + + "{\"productSku\":\"LIST_ONLY\",\"qty\":1}", "session", 99); + observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":null}," + + "{\"productSku\":\"S\",\"deliveryQty\":2,\"actualDeliveryQty\":1}", "session", 99); + var e = saved.poll(3, TimeUnit.SECONDS); + assertThat(e).isNotNull(); assertThat(e.skuSetEqual()).isFalse(); + var line = e.skus().stream().filter(v -> v.sku().equals("S")).findFirst().orElseThrow(); + assertThat(line.listQty()).isEqualByComparingTo("0.3"); + assertThat(line.forecastQty()).isEqualByComparingTo("3"); assertThat(line.actualQty()).isNull(); + assertThat(e.detailObservation().status()).isEqualTo("99"); assertThat(e.detailObservation().outboundTime()).isNull(); + assertThat(e.forecastActualDifference()).isFalse(); + } + } + @Test void crossSessionCannotPairAndExpiredCaptureDoesNotWrite() throws Exception { + try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { + observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "other", 100); + observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100); + assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull(); + } + try (var c = new CloseableCapture(capture(NOW, saved::add))) { + observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100); + assertThat(c.value.stopReason()).isEqualTo("EXPIRED"); assertThat(saved).isEmpty(); + } + } + @Test void sinkFailureStopsOnlyDiagnosticAndNeverEscapesCaller() throws Exception { + try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { throw new IllegalStateException("private"); }))) { + observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100); + observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100); + awaitStop(c.value, "DIAGNOSTIC_FAILED"); + } + } + @Test void hundredCompleteOrdersStopsFurtherCapture() throws Exception { + try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { + for (int i = 0; i < 100; i++) { + String head = "\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D" + i + "\",\"status\":100,"; + c.value.observe(mapper.readTree("{\"records\":[{" + head + "\"productList\":[{\"productSku\":\"S\",\"qty\":1}]}]}"), false, context("session"), NOW); + c.value.observe(mapper.readTree("{" + head + "\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}]}"), true, context("session"), NOW); + assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull(); + } + awaitStop(c.value, "ORDER_LIMIT"); + observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100); + assertThat(saved).isEmpty(); + } + } + @Test void thousandRowPageSelectsHundredAndSlowSinkDoesNotBlockDetails() throws Exception { + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { + entered.countDown(); + try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); } + saved.add(evidence); + }))) { + var page = mapper.createObjectNode(); var records = page.putArray("records"); + for (int i = 0; i < 1000; i++) { + var order = records.addObject().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100); + order.putArray("productList").addObject().put("productSku", "S").put("qty", 1); + } + c.value.observe(page, false, context("session"), NOW); + assertThat(c.value.stopReason()).isNull(); + // Unselected details arrive first: they must not consume queue or evidence capacity. + for (int i = 999; i >= 0; i--) { + var detail = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100); + detail.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1); + c.value.observe(detail, true, context("session"), NOW); + } + assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue(); + assertThat(c.value.stopReason()).isEqualTo("ORDER_LIMIT"); + release.countDown(); + for (int i = 0; i < 100; i++) assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull(); + assertThat(saved).isEmpty(); + } finally { release.countDown(); } + } + private static void awaitStop(LingxingQuantityCapture capture, String reason) throws Exception { + long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (capture.stopReason() == null && System.nanoTime() < end) Thread.sleep(5); + assertThat(capture.stopReason()).isEqualTo(reason); + } + @ParameterizedTest @ValueSource(strings = {"FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED"}) + void savedFinalOrderRetainsNormalReceiverReasonAndDiscardsQueuedOrders(String reason) throws Exception { + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { + entered.countDown(); + try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); } + saved.add(evidence); // Receiver persisted the last accepted order before returning its stop decision. + throw new LingxingQuantityEvidence.ReceiverStopped(reason, true); + }))) { + for (int i = 0; i < 3; i++) { + var order = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100); + order.putArray("productList").addObject().put("productSku", "S").put("qty", 1); + var page = mapper.createObjectNode(); page.putArray("records").add(order); + c.value.observe(page, false, context("session"), NOW); + order.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1); + c.value.observe(order, true, context("session"), NOW); + } + assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue(); + release.countDown(); awaitStop(c.value, reason); + assertThat(saved).hasSize(1); + assertThat(saved.remove().orderNo()).isEqualTo("D0"); + assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull(); + } finally { release.countDown(); } + } + private record CloseableCapture(LingxingQuantityCapture value) implements AutoCloseable { + @Override public void close() { value.close(); } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java new file mode 100644 index 000000000..6fb44e246 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java @@ -0,0 +1,65 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class LingxingQuantityHttpSinkTest { + @Test void exactContractAuthenticationAndNoRetryWhenReceiverStops() throws Exception { + var calls = new AtomicInteger(); var body = new AtomicReference(); var header = new AtomicReference(); + var outcome = new AtomicReference<>("ACCEPTED"); + var reason = new AtomicReference<>("null"); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders", exchange -> { + calls.incrementAndGet(); body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")); + byte[] response = ("{\"code\":\"200\",\"data\":{\"outcome\":\"" + outcome.get() + "\",\"stopReason\":" + reason.get() + "}}") + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); exchange.getResponseBody().write(response); exchange.close(); + }); server.start(); + try { + var props = new LingxingEgressProperties(CollectionMode.OFF, "test", null, null, null, + URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()) + .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + String token = "synthetic-internal-token-000000000000"; + var sink = new LingxingQuantityHttpSink(props, mapper, token); + var obs = new LingxingQuantityEvidence.Observation("99", null, Instant.parse("2026-10-13T00:00:00Z"), "attempt", true); + var evidence = new LingxingQuantityEvidence("diagnostic", "account", "root", "collection", "W", "C", "D", obs, obs, + List.of(new LingxingQuantityEvidence.Line("S", true, true, null, null, null))); + sink.accept(evidence); + var json = mapper.readTree(body.get()); + assertThat(header.get()).isEqualTo(token); assertThat(json.path("diagnosticSessionId").asText()).isEqualTo("diagnostic"); + assertThat(json.path("listObservation").path("observedAt").asText()).isEqualTo("2026-10-13T00:00:00Z"); + assertThat(json.path("skus").get(0).path("forecastQty").isNull()).isTrue(); + assertThat(json.has("skuSetEqual")).isFalse(); assertThat(json.has("forecastActualDifference")).isFalse(); + outcome.set("STOPPED"); assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOf(IllegalStateException.class); + assertThat(calls.get()).isEqualTo(2); + for (String stopReason : List.of("FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED")) { + outcome.set("ACCEPTED"); reason.set("\"" + stopReason + "\""); + assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying( + LingxingQuantityEvidence.ReceiverStopped.class, stopped -> { + assertThat(stopped.reason()).isEqualTo(stopReason); + assertThat(stopped.orderStored()).isTrue(); + }); + } + outcome.set("STOPPED"); reason.set("\"DISABLED\""); + assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying( + LingxingQuantityEvidence.ReceiverStopped.class, stopped -> { + assertThat(stopped.reason()).isEqualTo("DISABLED"); + assertThat(stopped.orderStored()).isFalse(); + }); + assertThat(calls.get()).isEqualTo(5); + } finally { server.stop(0); } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index e2d42ba2c..29a4dd64a 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -52,6 +52,24 @@ class LingxingWmsClientTest { server.start(); } @AfterEach void stop() { server.stop(0); } + @Test void observerFailureCannotChangeBusinessResponseOrIssueExtraRequests() { + var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); + org.mockito.Mockito.doThrow(new IllegalStateException("diagnostic failure")).when(observer) + .observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + var c = client(); c.setQuantityCapture(observer); + assertThat(c.queryPage(query(1, 1), context()).records()).hasSize(1); + assertThat(calls.get()).isEqualTo(1); + } + @Test void deniedOrRateLimitedRequestsNeverReachObserver() { + var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); + var disabled = client(CollectionMode.OFF, true, true); disabled.setQuantityCapture(observer); + assertThrows(LingxingWmsException.class, () -> disabled.queryPage(query(1, 1), context())); + assertThat(calls.get()).isZero(); + var enabled = client(); enabled.setQuantityCapture(observer); status.set(429); + assertThrows(LingxingWmsException.class, () -> enabled.queryPage(query(1, 1), context())); + org.mockito.Mockito.verifyNoInteractions(observer); assertThat(calls.get()).isEqualTo(1); + } @Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception { var result = client().queryPage(query(1, 1), context()); var data = mapper.readTree(body.get()).path("data"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticConfiguration.java new file mode 100644 index 000000000..0aef34600 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(QuantityDiagnosticProperties.class) +public class QuantityDiagnosticConfiguration { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java new file mode 100644 index 000000000..832548d4e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** Diagnostic evidence only; never consumed by sales publication. */ +public final class QuantityDiagnosticContract { + private QuantityDiagnosticContract() { } + public record Order(String diagnosticSessionId, String collectionSessionId, String rootTaskId, + String sourceAccount, String warehouseCode, String ownerCode, String orderNo, + Observation listObservation, Observation detailObservation, List skus) { } + public record Observation(String status, String outboundTime, Instant observedAt, String attemptId, + Boolean skuSetComplete) { } + public record Sku(String sku, BigDecimal listQty, BigDecimal forecastQty, BigDecimal actualQty, + Boolean listPresent, Boolean detailPresent) { } + public enum Comparison { + SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE + } + public record Receipt(String outcome, Comparison comparison, String stopReason, + int acceptedOrders, int acceptedSkuRows, long acceptedBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java new file mode 100644 index 000000000..e189db304 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.TransactionException; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/internal/lastleg/self-operated-analytics/quantity-diagnostics") +public class QuantityDiagnosticController { + private final InternalServiceTokenGuard guard; + private final QuantityDiagnosticService service; + private final ObjectMapper json; + public QuantityDiagnosticController(InternalServiceTokenGuard guard, QuantityDiagnosticService service, ObjectMapper json) { + this.guard = guard; this.service = service; + this.json = json.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + } + @PostMapping(value = "/orders", consumes = "application/json") + public ApiResponse accept( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody byte[] body) { + guard.verify(token); + if (body.length > QuantityDiagnosticPolicy.MAX_REQUEST_BYTES) + throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_REQUEST_TOO_LARGE"); + QuantityDiagnosticContract.Order order; + try { order = json.readValue(body, QuantityDiagnosticContract.Order.class); } + catch (IOException e) { throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_JSON_INVALID"); } + try { return ApiResponse.success(service.accept(order)); } + catch (DataAccessException | TransactionException e) { + // No SQL, request payload or credentials are returned to the caller. + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "DIAGNOSTIC_STORAGE_UNAVAILABLE"); + } + } + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> rejected(ResponseStatusException error) { + ResultCode code = switch (error.getStatusCode().value()) { + case 401 -> ResultCode.UNAUTHORIZED; + case 409 -> ResultCode.CONFLICT; + case 503 -> ResultCode.SERVICE_UNAVAILABLE; + default -> ResultCode.BAD_REQUEST; + }; + return ResponseEntity.status(error.getStatusCode()).body(ApiResponse.failure(code, error.getReason(), null, null)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java new file mode 100644 index 000000000..0c1ddd085 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +final class QuantityDiagnosticPolicy { + static final int MAX_ORDERS = 100, MAX_ROWS = 2000, MAX_ORDER_ROWS = 200; + static final int MAX_BYTES = 2 * 1024 * 1024, MAX_REQUEST_BYTES = 256 * 1024; + private QuantityDiagnosticPolicy() { } + static ResponseStatusException invalid(String code) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, code); + } + static void text(String value, int maximum) { + if (value == null || value.isBlank() || value.length() > maximum + || value.codePoints().anyMatch(Character::isISOControl)) throw invalid("DIAGNOSTIC_FIELD_INVALID"); + } + static void settings(QuantityDiagnosticProperties p) { + text(p.diagnosticSessionId(), 128); text(p.collectionSessionId(), 128); + text(p.rootTaskId(), 128); text(p.sourceAccount(), 64); + if (p.startsAt() == null || p.expiresAt() == null || !p.expiresAt().isAfter(p.startsAt()) + || Duration.between(p.startsAt(), p.expiresAt()).compareTo(Duration.ofMinutes(30)) > 0 + || p.warehouseCodes() == null || p.warehouseCodes().isEmpty() || p.warehouseCodes().size() > 20) + throw invalid("DIAGNOSTIC_CONFIGURATION_INVALID"); + p.warehouseCodes().forEach(w -> text(w, 64)); + } + static Order normalize(Order r, QuantityDiagnosticProperties p, Instant now) { + if (r == null) throw invalid("DIAGNOSTIC_ORDER_REQUIRED"); + if (!p.diagnosticSessionId().equals(r.diagnosticSessionId()) + || !p.collectionSessionId().equals(r.collectionSessionId()) || !p.rootTaskId().equals(r.rootTaskId()) + || !p.sourceAccount().equals(r.sourceAccount()) || !p.warehouseCodes().contains(r.warehouseCode())) + throw invalid("DIAGNOSTIC_SCOPE_MISMATCH"); + text(r.ownerCode(), 128); text(r.orderNo(), 128); + observation(r.listObservation(), p, now); observation(r.detailObservation(), p, now); + if (r.skus() == null || r.skus().isEmpty() || r.skus().size() > MAX_ORDER_ROWS) + throw invalid("DIAGNOSTIC_SKU_LIMIT"); + var seen = new HashSet(); + for (Sku s : r.skus()) { + if (s == null) throw invalid("DIAGNOSTIC_SKU_REQUIRED"); + text(s.sku(), 128); + if (!seen.add(s.sku())) throw invalid("DIAGNOSTIC_DUPLICATE_SKU"); + if (s.listPresent() == null || s.detailPresent() == null || (!s.listPresent() && !s.detailPresent())) + throw invalid("DIAGNOSTIC_PRESENCE_INVALID"); + if ((!s.listPresent() && s.listQty() != null) + || (!s.detailPresent() && (s.forecastQty() != null || s.actualQty() != null))) + throw invalid("DIAGNOSTIC_ABSENT_QUANTITY"); + quantity(s.listQty()); quantity(s.forecastQty()); quantity(s.actualQty()); + } + List skus = r.skus().stream().sorted(Comparator.comparing(Sku::sku)) + .map(s -> new Sku(s.sku(), decimal(s.listQty()), decimal(s.forecastQty()), decimal(s.actualQty()), + s.listPresent(), s.detailPresent())).toList(); + return new Order(r.diagnosticSessionId(), r.collectionSessionId(), r.rootTaskId(), r.sourceAccount(), + r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), r.detailObservation(), skus); + } + private static void observation(Observation o, QuantityDiagnosticProperties p, Instant now) { + if (o == null || o.observedAt() == null || o.skuSetComplete() == null) + throw invalid("DIAGNOSTIC_OBSERVATION_REQUIRED"); + text(o.attemptId(), 128); + if (o.status() != null) text(o.status(), 32); + if (o.outboundTime() != null) text(o.outboundTime(), 32); + if (o.observedAt().isBefore(p.startsAt()) || o.observedAt().isAfter(p.expiresAt()) + || o.observedAt().isAfter(now)) throw invalid("DIAGNOSTIC_OBSERVATION_OUTSIDE_SESSION"); + } + private static BigDecimal decimal(BigDecimal v) { return v == null ? null : v.stripTrailingZeros(); } + private static void quantity(BigDecimal v) { + if (v != null && (v.signum() < 0 || v.scale() > 6 || v.precision() - v.scale() > 32)) + throw invalid("DIAGNOSTIC_QUANTITY_INVALID"); + } + static boolean forecastDifference(Order r) { + return r.skus().stream().anyMatch(s -> s.forecastQty() != null && s.actualQty() != null + && s.forecastQty().compareTo(s.actualQty()) != 0); + } + static Comparison compare(Order r) { + if (!r.listObservation().skuSetComplete() || !r.detailObservation().skuSetComplete() + || r.skus().stream().anyMatch(s -> !s.listPresent() || !s.detailPresent())) + return Comparison.SKU_SET_MISMATCH; + if (r.skus().stream().anyMatch(s -> s.listQty() == null || s.forecastQty() == null || s.actualQty() == null)) + return Comparison.QUANTITY_UNKNOWN; + if (forecastDifference(r)) return Comparison.FORECAST_ACTUAL_DIFFERENCE; + return r.skus().stream().allMatch(s -> s.listQty().compareTo(s.actualQty()) == 0) + ? Comparison.ALL_EQUAL : Comparison.LIST_QUANTITY_DIFFERENCE; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java new file mode 100644 index 000000000..d65207e9e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import java.time.Instant; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties("baiyue.selfop.quantity-diagnostics") +public record QuantityDiagnosticProperties(@DefaultValue("false") boolean enabled, + String diagnosticSessionId, String collectionSessionId, String rootTaskId, String sourceAccount, + List warehouseCodes, Instant startsAt, Instant expiresAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java new file mode 100644 index 000000000..c74396862 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java @@ -0,0 +1,125 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*; +import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticPolicy.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +/** One short transaction per paired order; only the two diagnostic tables are written. */ +@Service +public class QuantityDiagnosticService { + private final JdbcTemplate jdbc; + private final TransactionTemplate tx; + private final ObjectMapper json; + private final QuantityDiagnosticProperties properties; + private final Clock clock; + + @Autowired + public QuantityDiagnosticService(JdbcTemplate jdbc, PlatformTransactionManager manager, + ObjectMapper json, QuantityDiagnosticProperties properties) { + this(jdbc, manager, json, properties, Clock.systemUTC()); + } + QuantityDiagnosticService(JdbcTemplate jdbc, PlatformTransactionManager manager, + ObjectMapper json, QuantityDiagnosticProperties properties, Clock clock) { + this.jdbc = jdbc; this.json = json.copy(); this.properties = properties; this.clock = clock; + this.tx = new TransactionTemplate(manager); this.tx.setTimeout(3); + } + public Receipt accept(Order input) { + if (!properties.enabled()) return new Receipt("STOPPED", null, "DISABLED", 0, 0, 0); + settings(properties); + Order order = normalize(input, properties, clock.instant()); + String payload = encode(order); + int bytes = payload.getBytes(StandardCharsets.UTF_8).length; + if (bytes > MAX_REQUEST_BYTES) throw invalid("DIAGNOSTIC_REQUEST_TOO_LARGE"); + String key = hash(encode(List.of(order.sourceAccount(), order.warehouseCode(), order.ownerCode(), order.orderNo()))); + String payloadHash = hash(payload); + String configuration = hash(encode(List.of("v1", properties.diagnosticSessionId(), properties.collectionSessionId(), + properties.rootTaskId(), properties.sourceAccount(), properties.warehouseCodes().stream().sorted().distinct().toList(), + properties.startsAt(), properties.expiresAt(), MAX_ORDERS, MAX_ROWS, MAX_BYTES))); + return tx.execute(status -> persist(order, key, payload, payloadHash, bytes, configuration)); + } + private Receipt persist(Order order, String key, String payload, String payloadHash, int bytes, String configuration) { + String id = order.diagnosticSessionId(); + // Duplicate insert acquires the same durable session lock, including first-request races. + jdbc.update(""" + INSERT INTO ll_selfop_quantity_diagnostic_session + (diagnostic_session_id, configuration_hash, source_account, collection_session_id, root_task_id, + starts_at, expires_at, accepted_orders, accepted_sku_rows, accepted_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0) + ON DUPLICATE KEY UPDATE diagnostic_session_id = diagnostic_session_id + """, id, configuration, order.sourceAccount(), order.collectionSessionId(), order.rootTaskId(), + properties.startsAt().toString(), properties.expiresAt().toString()); + Session session = jdbc.queryForObject(""" + SELECT configuration_hash, accepted_orders, accepted_sku_rows, accepted_bytes, stop_reason + FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id = ? FOR UPDATE + """, (rs, n) -> new Session(rs.getString(1), rs.getInt(2), rs.getInt(3), rs.getLong(4), rs.getString(5)), id); + if (session == null || !configuration.equals(session.configurationHash())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "DIAGNOSTIC_SESSION_CONFIGURATION_CHANGED"); + var existing = jdbc.query(""" + SELECT payload_hash, comparison FROM ll_selfop_quantity_diagnostic_order + WHERE diagnostic_session_id = ? AND order_key_hash = ? + """, (rs, n) -> new Existing(rs.getString(1), Comparison.valueOf(rs.getString(2))), id, key); + if (!existing.isEmpty()) { + if (!payloadHash.equals(existing.get(0).payloadHash())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "DIAGNOSTIC_ORDER_CONFLICT"); + return session.receipt("DUPLICATE", existing.get(0).comparison(), session.stopReason()); + } + if (session.stopReason() != null) return session.receipt("STOPPED", null, session.stopReason()); + Instant now = clock.instant(); + if (now.isBefore(properties.startsAt())) return session.receipt("STOPPED", null, "NOT_STARTED"); + if (!now.isBefore(properties.expiresAt())) return stop(id, session, "EXPIRED"); + if (session.orders() >= MAX_ORDERS || session.rows() + order.skus().size() > MAX_ROWS + || session.bytes() + bytes > MAX_BYTES) return stop(id, session, "LIMIT_REACHED"); + Comparison comparison = compare(order); + jdbc.update(""" + INSERT INTO ll_selfop_quantity_diagnostic_order + (diagnostic_session_id, order_key_hash, source_account, warehouse_code, owner_code, order_no, + payload_hash, comparison, sku_rows, payload_bytes, evidence_json, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, id, key, order.sourceAccount(), order.warehouseCode(), order.ownerCode(), order.orderNo(), + payloadHash, comparison.name(), order.skus().size(), bytes, payload, now.toString()); + int orders = session.orders() + 1, rows = session.rows() + order.skus().size(); + long totalBytes = session.bytes() + bytes; + String reason = forecastDifference(order) ? "FORECAST_ACTUAL_DIFFERENCE" + : orders == MAX_ORDERS || rows == MAX_ROWS || totalBytes == MAX_BYTES ? "LIMIT_REACHED" : null; + int updated = jdbc.update(""" + UPDATE ll_selfop_quantity_diagnostic_session + SET accepted_orders = ?, accepted_sku_rows = ?, accepted_bytes = ?, stop_reason = ? + WHERE diagnostic_session_id = ? + """, orders, rows, totalBytes, reason, id); + if (updated != 1) throw new IllegalStateException("DIAGNOSTIC_SESSION_UPDATE_FAILED"); + return new Receipt("ACCEPTED", comparison, reason, orders, rows, totalBytes); + } + private Receipt stop(String id, Session session, String reason) { + jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET stop_reason = ? WHERE diagnostic_session_id = ?", reason, id); + return session.receipt("STOPPED", null, reason); + } + private String encode(Object value) { + try { return json.writeValueAsString(value); } + catch (JsonProcessingException e) { throw invalid("DIAGNOSTIC_ENCODING_FAILED"); } + } + private static String hash(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + private record Existing(String payloadHash, Comparison comparison) { } + private record Session(String configurationHash, int orders, int rows, long bytes, String stopReason) { + Receipt receipt(String outcome, Comparison comparison, String reason) { + return new Receipt(outcome, comparison, reason, orders, rows, bytes); + } + } +} diff --git a/lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql b/lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql new file mode 100644 index 000000000..eec9e8ae3 --- /dev/null +++ b/lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql @@ -0,0 +1,38 @@ +-- Independent diagnostic tables in the Lastleg schema. No business fact/queue writes. +-- Run only after explicit TEST SQL approval. Not executed by service startup. +CREATE TABLE ll_selfop_quantity_diagnostic_session ( + diagnostic_session_id VARCHAR(128) NOT NULL COMMENT '固定有界数量诊断会话标识', + configuration_hash CHAR(64) NOT NULL COMMENT '冻结范围、UTC起止时间及限额的SHA256摘要', + source_account VARCHAR(64) NOT NULL COMMENT '来源账号别名,不保存凭据', + collection_session_id VARCHAR(128) NOT NULL COMMENT '被观察的来源采集会话标识', + root_task_id VARCHAR(128) NOT NULL COMMENT '被观察的账号采集根任务标识', + starts_at VARCHAR(40) NOT NULL COMMENT '诊断开始时刻,ISO8601 UTC字符串', + expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,距开始最多30分钟,ISO8601 UTC字符串', + accepted_orders INT NOT NULL DEFAULT 0 COMMENT '已接收完整配对订单数,跨重启累计', + accepted_sku_rows INT NOT NULL DEFAULT 0 COMMENT '已接收归并SKU行数,跨重启累计', + accepted_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '已持久化白名单规范JSON的UTF8字节数', + stop_reason VARCHAR(64) NULL COMMENT '诊断终止原因,空值表示尚未终止', + PRIMARY KEY (diagnostic_session_id), + CONSTRAINT chk_selfop_qdiag_limits CHECK (accepted_orders BETWEEN 0 AND 100 + AND accepted_sku_rows BETWEEN 0 AND 2000 AND accepted_bytes BETWEEN 0 AND 2097152) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='独立数量对照诊断会话,重启不重置限额'; + +CREATE TABLE ll_selfop_quantity_diagnostic_order ( + diagnostic_session_id VARCHAR(128) NOT NULL COMMENT '所属固定诊断会话标识', + order_key_hash CHAR(64) NOT NULL COMMENT '账号、仓库、货主、出库单业务键的SHA256摘要', + source_account VARCHAR(64) NOT NULL COMMENT '来源账号别名', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + owner_code VARCHAR(128) NOT NULL COMMENT '来源货主编码', + order_no VARCHAR(128) NOT NULL COMMENT '来源出库单号', + payload_hash CHAR(64) NOT NULL COMMENT '白名单规范载荷SHA256摘要,用于幂等校验', + comparison VARCHAR(40) NOT NULL COMMENT 'SKU集合与三类数量比较结果', + sku_rows INT NOT NULL COMMENT '本单归并后的SKU并集行数', + payload_bytes INT NOT NULL COMMENT '本单白名单规范JSON的UTF8字节数', + evidence_json JSON NOT NULL COMMENT '类型化列表与详情观测及SKU三量证据,不含原始响应或收件信息', + received_at VARCHAR(40) NOT NULL COMMENT '接收时刻,ISO8601 UTC字符串', + PRIMARY KEY (diagnostic_session_id, order_key_hash), + CONSTRAINT fk_selfop_qdiag_session FOREIGN KEY (diagnostic_session_id) + REFERENCES ll_selfop_quantity_diagnostic_session(diagnostic_session_id), + CONSTRAINT chk_selfop_qdiag_order_limits CHECK (sku_rows BETWEEN 1 AND 200 + AND payload_bytes BETWEEN 1 AND 262144) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='独立配对订单数量诊断证据,不参与销量发布'; diff --git a/lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml b/lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml new file mode 100644 index 000000000..63708501c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml @@ -0,0 +1,105 @@ +openapi: 3.0.3 +info: + title: Internal bounded self-operated quantity diagnostics + version: 1.0.0 +paths: + /internal/lastleg/self-operated-analytics/quantity-diagnostics/orders: + post: + operationId: receiveSelfopQuantityDiagnosticOrder + description: >- + Internal service-token only; never routed through Gateway. One complete paired order per request. + Quantities are evidence only. Session scope and UTC time interval are frozen by server configuration. + Maximum 100 orders, 2000 SKU rows and 2097152 canonical UTF8 bytes per session; + 200 SKU rows and 262144 wire/canonical bytes per request; at most 30 minutes. + First known forecast/actual difference closes capture regardless of comparison classification. + Same identity and canonical evidence is idempotent, including after closure; changed evidence is 409. + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PairedOrder' + responses: + '200': + description: Diagnostic receipt in the standard ApiResponse wrapper; code is string 200. + content: + application/json: + schema: + type: object + properties: + code: { type: string, example: '200' } + data: { $ref: '#/components/schemas/Receipt' } + '400': { description: 'Invalid JSON, unknown fields, invalid scope/evidence, duplicate SKU or request limit.' } + '401': { description: Invalid internal service token. } + '409': { description: Session configuration changed or order identity reused with different evidence. } + '503': { description: Internal service token unconfigured or diagnostic storage unavailable. } +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + schemas: + PairedOrder: + type: object + additionalProperties: false + required: [diagnosticSessionId, collectionSessionId, rootTaskId, sourceAccount, warehouseCode, ownerCode, orderNo, listObservation, detailObservation, skus] + properties: + diagnosticSessionId: { type: string, maxLength: 128 } + collectionSessionId: { type: string, maxLength: 128 } + rootTaskId: { type: string, maxLength: 128 } + sourceAccount: { type: string, maxLength: 64 } + warehouseCode: { type: string, maxLength: 64 } + ownerCode: { type: string, maxLength: 128 } + orderNo: { type: string, maxLength: 128 } + listObservation: { $ref: '#/components/schemas/Observation' } + detailObservation: { $ref: '#/components/schemas/Observation' } + skus: + type: array + minItems: 1 + maxItems: 200 + description: Union of list/detail SKUs; duplicate source lines aggregated, unknown never zero-filled. + items: { $ref: '#/components/schemas/Sku' } + Observation: + type: object + additionalProperties: false + required: [observedAt, attemptId, skuSetComplete] + properties: + status: { type: string, nullable: true, maxLength: 32 } + outboundTime: + type: string + nullable: true + maxLength: 32 + description: Original Asia/Shanghai source text, null if absent; not reinterpreted as another timezone. + observedAt: { type: string, format: date-time } + attemptId: { type: string, maxLength: 128 } + skuSetComplete: { type: boolean } + Sku: + type: object + additionalProperties: false + required: [sku, listPresent, detailPresent] + properties: + sku: { type: string, maxLength: 128 } + listQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 } + forecastQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 } + actualQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 } + listPresent: { type: boolean } + detailPresent: { type: boolean } + Receipt: + type: object + required: [outcome, acceptedOrders, acceptedSkuRows, acceptedBytes] + properties: + outcome: { type: string, enum: [ACCEPTED, DUPLICATE, STOPPED] } + comparison: + type: string + nullable: true + enum: [SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE] + stopReason: + type: string + nullable: true + enum: [DISABLED, NOT_STARTED, EXPIRED, LIMIT_REACHED, FORECAST_ACTUAL_DIFFERENCE] + acceptedOrders: { type: integer } + acceptedSkuRows: { type: integer } + acceptedBytes: { type: integer, format: int64, description: Canonical whitelist JSON UTF8 bytes persisted in this session. } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java new file mode 100644 index 000000000..3e36b1723 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class QuantityDiagnosticControllerTest { + @Test void httpStatusContractSurvivesSharedBusinessErrorAdvice() throws Exception { + var service = mock(QuantityDiagnosticService.class); + var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper()); + var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new com.baiyue.common.web.GlobalExceptionHandler()).build(); + String path = "/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders"; + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path) + .contentType("application/json").content("{}")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized()); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path) + .header("X-Internal-Service-Token", "test-token").contentType("application/json").content("{\"address\":1}")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest()); + } + @Test void rejectsInvalidTokenBeforeParsingOrStorage() { + var service = mock(QuantityDiagnosticService.class); + var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper()); + assertThatThrownBy(() -> controller.accept(null, new byte[0])).hasMessageContaining("401"); + assertThatThrownBy(() -> controller.accept("wrong", new byte[0])).hasMessageContaining("401"); + verifyNoInteractions(service); + } + @Test void missingTokenConfigurationFailsClosed() { + var service = mock(QuantityDiagnosticService.class); + var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard(""), service, new ObjectMapper()); + assertThatThrownBy(() -> controller.accept("any", new byte[0])).hasMessageContaining("503"); + verifyNoInteractions(service); + } + @Test void rejectsNonWhitelistedFieldsTrailingDocumentsAndOversizedPayload() { + var service = mock(QuantityDiagnosticService.class); + var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper()); + assertThatThrownBy(() -> controller.accept("test-token", "{\"address\":\"not allowed\"}".getBytes(StandardCharsets.UTF_8))) + .hasMessageContaining("DIAGNOSTIC_JSON_INVALID"); + assertThatThrownBy(() -> controller.accept("test-token", "{} {}".getBytes(StandardCharsets.UTF_8))) + .hasMessageContaining("DIAGNOSTIC_JSON_INVALID"); + assertThatThrownBy(() -> controller.accept("test-token", new byte[262145])).hasMessageContaining("DIAGNOSTIC_REQUEST_TOO_LARGE"); + verifyNoInteractions(service); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java new file mode 100644 index 000000000..d0ab0ab8b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java @@ -0,0 +1,169 @@ +package com.baiyue.lastleg.selfoperated.analytics.diagnostic; + +import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*; +import static org.assertj.core.api.Assertions.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.web.server.ResponseStatusException; + +class QuantityDiagnosticServiceTest { + private final Instant start = Instant.parse("2026-10-13T15:00:00Z"); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private JdbcTemplate jdbc; + private DataSourceTransactionManager manager; + private QuantityDiagnosticService service; + private QuantityDiagnosticProperties properties; + + @BeforeEach void prepare() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000", "sa", ""); + jdbc = new JdbcTemplate(ds); manager = new DataSourceTransactionManager(ds); + // Exercise the dedicated production DDL, omitting only MySQL table options/comments. + String ddl; + try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) { + ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + ddl = ddl.replaceAll("(?m)^--.*$", "").replaceAll("COMMENT\\s*=?\\s*'[^']*'", "") + .replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", ""); + for (String statement : ddl.split(";")) if (!statement.isBlank()) jdbc.execute(statement); + properties = config(start.plusSeconds(1800), true); + service = instance(properties, start.plusSeconds(10)); + } + private QuantityDiagnosticProperties config(Instant expires, boolean enabled) { + return new QuantityDiagnosticProperties(enabled, "diag-1", "collection-1", "root-1", "account-1", + List.of("warehouse-1"), start, expires); + } + private QuantityDiagnosticService instance(QuantityDiagnosticProperties p, Instant now) { + return new QuantityDiagnosticService(jdbc, manager, json, p, Clock.fixed(now, ZoneOffset.UTC)); + } + private Sku sku(String code, String list, String forecast, String actual) { + return new Sku(code, list == null ? null : new BigDecimal(list), forecast == null ? null : new BigDecimal(forecast), + actual == null ? null : new BigDecimal(actual), true, true); + } + private Order order(String no, Sku... skus) { + return new Order("diag-1", "collection-1", "root-1", "account-1", "warehouse-1", "owner-1", no, + new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(1), "list-1", true), + new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(2), "detail-1", true), List.of(skus)); + } + @Test void firstDifferencePersistsEvidenceAndStopsFurtherOrdersIncludingAfterRestart() { + var request = order("order-1", sku("sku-1", "10", "10", "8")); + var result = service.accept(request); + assertThat(result.outcome()).isEqualTo("ACCEPTED"); + assertThat(result.comparison()).isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE); + assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE"); + service = instance(properties, start.plusSeconds(20)); + assertThat(service.accept(request).outcome()).isEqualTo("DUPLICATE"); + assertThat(service.accept(order("order-2", sku("sku-1", "1", "1", "1"))).outcome()).isEqualTo("STOPPED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1); + } + @Test void listMatchingActualStillRecordsForecastDifference() { + assertThat(service.accept(order("order-1", sku("sku", "8", "10", "8"))).comparison()) + .isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE); + } + @Test void idempotencyCanonicalizesDecimalsAndRowOrderButRejectsChangedEvidence() { + service.accept(order("order-1", sku("b", "1.0", "1", "1"), sku("a", "2", "2", "2"))); + var result = service.accept(order("order-1", sku("a", "2.00", "2", "2"), sku("b", "1", "1", "1"))); + assertThat(result.outcome()).isEqualTo("DUPLICATE"); + assertThat(result.acceptedOrders()).isEqualTo(1); + assertThatThrownBy(() -> service.accept(order("order-1", sku("a", "3", "3", "3")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + } + @Test void skuReplacementCannotBeHiddenByEqualTotalsAndDifferenceStillStops() { + var result = service.accept(order("order-1", + new Sku("old", BigDecimal.TEN, null, null, true, false), + new Sku("new", null, BigDecimal.TEN, BigDecimal.valueOf(8), false, true))); + assertThat(result.comparison()).isEqualTo(Comparison.SKU_SET_MISMATCH); + assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE"); + } + @Test void missingQuantityRemainsUnknown() { + var result = service.accept(order("order-1", sku("sku", null, "1", "1"))); + assertThat(result.comparison()).isEqualTo(Comparison.QUANTITY_UNKNOWN); + assertThat(result.stopReason()).isNull(); + } + @Test void duplicateSkusAndCrossGenerationAreRejectedWithoutWrites() { + assertThatThrownBy(() -> service.accept(order("order-1", sku("sku", "1", "1", "1"), sku("sku", "1", "1", "1")))) + .hasMessageContaining("DIAGNOSTIC_DUPLICATE_SKU"); + Order r = order("order-1", sku("sku", "1", "1", "1")); + assertThatThrownBy(() -> service.accept(new Order(r.diagnosticSessionId(), "other-generation", r.rootTaskId(), r.sourceAccount(), + r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), r.detailObservation(), r.skus()))) + .hasMessageContaining("DIAGNOSTIC_SCOPE_MISMATCH"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero(); + } + @Test void expiredAndReconfiguredSessionsCannotResetCounters() { + var r = order("order-1", sku("sku", "1", "1", "1")); service.accept(r); + var expired = instance(properties, start.plusSeconds(1800)); + assertThat(expired.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("EXPIRED"); + assertThat(expired.accept(r).outcome()).isEqualTo("DUPLICATE"); + assertThatThrownBy(() -> instance(config(start.plusSeconds(1799), true), start.plusSeconds(20)).accept(r)) + .hasMessageContaining("DIAGNOSTIC_SESSION_CONFIGURATION_CHANGED"); + } + @Test void disabledRequiresNoTablesOrValidSession() { + jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_order"); + jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_session"); + assertThat(instance(config(start.plusSeconds(1800), false), start).accept(null).stopReason()).isEqualTo("DISABLED"); + } + @Test void concurrentRequestsCannotExceedHundredOrders() throws Exception { + var pool = Executors.newFixedThreadPool(4); + try { + List> jobs = new ArrayList<>(); + for (int i = 0; i < 104; i++) { final int n = i; jobs.add(() -> service.accept(order("order-" + n, sku("sku", "1", "1", "1")))); } + var results = pool.invokeAll(jobs); + int accepted = 0; + for (var result : results) if (result.get().outcome().equals("ACCEPTED")) accepted++; + assertThat(accepted).isEqualTo(100); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(100); + assertThat(instance(properties, start.plusSeconds(20)).accept(order("after-restart", sku("sku", "1", "1", "1"))).stopReason()) + .isEqualTo("LIMIT_REACHED"); + } finally { pool.shutdownNow(); } + } + @Test void rowLimitAndFailedOrderInsertDoNotPartiallyAdvanceCounters() { + service.accept(order("order-1", sku("sku", "1", "1", "1"))); + jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=2000"); + assertThat(service.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("LIMIT_REACHED"); + assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isEqualTo(1); + } + @Test void byteLimitCannotBeResetByRestartOrPartiallySaveOrder() { + service.accept(order("order-1", sku("sku", "1", "1", "1"))); + jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_bytes=2097150"); + var receipt = instance(properties, start.plusSeconds(20)).accept(order("order-2", sku("sku", "1", "1", "1"))); + assertThat(receipt.stopReason()).isEqualTo("LIMIT_REACHED"); + assertThat(receipt.acceptedBytes()).isEqualTo(2097150); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1); + } + @Test void dedicatedDdlHasChineseCommentsOnEveryTableAndOrdinaryColumn() throws Exception { + String ddl; + try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) { + ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + var fields = java.util.regex.Pattern.compile("(?m)^ [a-z][a-z_]+ (?:VARCHAR|CHAR|INT|BIGINT|JSON)\\b[^\\n]*").matcher(ddl); + int count = 0; + while (fields.find()) { + assertThat(fields.group()).matches("(?s).*COMMENT '[^']*[\\u4e00-\\u9fff][^']*'.*"); count++; + } + assertThat(count).isEqualTo(23); + assertThat(java.util.regex.Pattern.compile("COMMENT='[^']*[\\u4e00-\\u9fff][^']*';").matcher(ddl).results().count()).isEqualTo(2); + } + @Test void storageFailureRollsBackSessionCounterAndSessionCreation() { + jdbc.execute("ALTER TABLE ll_selfop_quantity_diagnostic_order ADD CONSTRAINT reject_test_order CHECK(order_no <> 'bad-order')"); + assertThatThrownBy(() -> service.accept(order("bad-order", sku("sku", "1", "1", "1")))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero(); + } + @Test void cancellationAndMissingOutboundTimeRemainEvidenceOnly() { + var r = order("cancelled", sku("sku", "1", "1", "1")); + var cancelled = new Observation("cancelled", null, start.plusSeconds(2), "detail-cancel", true); + var result = service.accept(new Order(r.diagnosticSessionId(), r.collectionSessionId(), r.rootTaskId(), r.sourceAccount(), + r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), cancelled, r.skus())); + assertThat(result.comparison()).isEqualTo(Comparison.ALL_EQUAL); + assertThat(result.acceptedOrders()).isEqualTo(1); + } +} From d029f9a19e69076e861fe6f696754c2313915c34 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 00:07:12 +0800 Subject: [PATCH 60/80] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + lastleg-service/AGENTS.md | 12 +- lastleg-service/docs/business-index.md | 14 ++ .../PlatformInventoryTurnoverController.java | 30 ++++ .../query/InventoryTurnoverContext.java | 71 ++++++++ .../query/InventoryTurnoverPolicy.java | 78 +++++++++ .../query/InventoryTurnoverQueryService.java | 33 ++++ .../query/InventoryTurnoverReadMapper.java | 45 +++++ .../query/InventoryTurnoverRequest.java | 49 ++++++ .../query/InventoryTurnoverResponses.java | 31 ++++ .../analytics/query/InventoryTurnoverSql.java | 158 ++++++++++++++++++ .../query/InventoryTurnoverSqlTest.java | 115 +++++++++++++ scripts/AGENTS.md | 2 +- scripts/README.md | 31 ++-- scripts/agents-index-contract.ps1 | 3 +- .../agents-index-registry-contract.test.mjs | 10 +- 16 files changed, 655 insertions(+), 29 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java diff --git a/AGENTS.md b/AGENTS.md index 35f4819ff..c4a00bd25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,8 @@ | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | +| 自营领星采集、经营统计、SKU 日销量 | `integration-service/` 采集适配、`lastleg-service/selfoperated/` 查询;按实际链路只读相关模块 | +| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` | | 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 | | PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index c271e5fe6..6d6d78c30 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -106,17 +106,7 @@ ## 数据库表索引与注释约束 -| 业务域 | 表名 | 中文含义 | -|---|---|---| -| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 | -| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | -| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | -| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 | -| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | -| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | -| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | -| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | -| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | +表名与中文含义按需查 `docs/business-index.md` 的“数据库表索引”;修改约束如下。 - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 72fbaa3bf..490707f70 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -66,3 +66,17 @@ | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/lastleg/` | | 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | + +## 数据库表索引 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 | +| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | +| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | +| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 | +| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | +| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | +| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | +| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | +| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverController.java new file mode 100644 index 000000000..2f9d89ebf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverController.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.query.*; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics") +@PreAuthorize("hasAuthority('platform:self-operated-analytics:list')") +public class PlatformInventoryTurnoverController { + private final InventoryTurnoverQueryService service; + public PlatformInventoryTurnoverController(InventoryTurnoverQueryService service){this.service=service;} + @GetMapping("/inventory-turnover") + @Operation(summary="固定版本的7/15/30日SKU与货主库存周转") + public ApiResponse query(@AuthenticationPrincipal AuthenticatedUser user, + @ModelAttribute InventoryTurnoverRequest request,@RequestParam Map parameters) { + if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0L||user.impersonated()||!user.permissions().contains(PlatformAnalyticsController.READ_PERMISSION)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"平台自营分析只读权限不足"); + InventoryTurnoverRequest.validateParameters(parameters.keySet()); + return ApiResponse.success(service.query(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java new file mode 100644 index 000000000..a8a0dfe92 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; + +/** Tokens carry evidence, not authority: all versions and missing dates are reconstructed at asOf. */ +@Component +public class InventoryTurnoverContext { + static final ZoneId ZONE=ZoneId.of("Asia/Shanghai"); + static final String RULE="DAILY_SCHEDULED_FIRST_V1"; + private final AnalyticsReadMapper analytics; + private final InventoryTurnoverReadMapper mapper; + private final ObjectMapper json; + private final Clock clock; + @Autowired public InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json) { + this(analytics,mapper,json,Clock.systemUTC()); + } + InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json,Clock clock) { + this.analytics=analytics;this.mapper=mapper;this.json=json;this.clock=clock; + } + public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf, + Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild, + List samples,LocalDate accumulationStartedOn) {} + record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {} + Frozen resolve(InventoryTurnoverRequest q) { + Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate()); + Evidence supplied=q.snapshotContext()==null?null:decode(q.snapshotContext()); + Instant asOf=supplied==null?now.truncatedTo(ChronoUnit.MICROS):supplied.asOf(); + if(asOf==null||asOf.isAfter(now)||!now.isBefore(asOf.plusSeconds(1800))) throw refresh(); + LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate()); + if(supplied!=null&&(!Objects.equals(q.sourceAccount(),supplied.account())||!Objects.equals(q.warehouseCode(),supplied.warehouse()) + ||!end.equals(supplied.end())||q.days()!=supplied.days())) throw refresh(); + var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode()); + var sales=version(scopes,"OUTBOUND",asOf); var stock=version(scopes,"INVENTORY",asOf); + var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(29),end,"DAY",null,1,20, + sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); + // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. + var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null); + var mapping=new TreeMap(); + for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),utc(asOf),end.minusDays(29),end.plusDays(1))) { + if(sample.sampleDate()==null||sample.runId()==null||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); + } + List samples=new ArrayList<>(); + for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); + var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),utc(asOf)); + var c=snapshot.context(); + var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(), + c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began); + if(supplied!=null&&!supplied.equals(evidence)) throw refresh(); + return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c, + c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.runId()==null).map(Sample::date).toList(),began),snapshot); + } + private AnalyticsReadRows.Run version(List scopes,String family,Instant asOf) { + var scope=scopes.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null); + return scope==null?null:mapper.versionAt(scope.id(),scope.scopeHash(),utc(asOf)); + } + private Evidence decode(String token) { + try {var value=json.readValue(Base64.getUrlDecoder().decode(token),Evidence.class);if(value==null)throw refresh();return value;} catch(Exception e){throw refresh();} + } + private String encode(Evidence evidence) { + try{return Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(evidence));}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);} + } + private static LocalDateTime utc(Instant value){return LocalDateTime.ofInstant(value,ZoneOffset.UTC);} + private static BizException refresh(){return new BizException("库存周转上下文无效或已过期,请整体刷新");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java new file mode 100644 index 000000000..9f377c52e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; + +/** Maps separately qualified quantities; a missing value never becomes a formal zero. */ +public final class InventoryTurnoverPolicy { + private InventoryTurnoverPolicy() {} + static Map normalized(Map source) { + Map result=new HashMap<>(); + if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value)); + return result; + } + static BigDecimal decimal(Map row,String key) { + Object value=row.get(key);return value==null?null:new BigDecimal(value.toString()); + } + static int count(Map row,String key){BigDecimal value=decimal(row,key);return value==null?0:value.intValueExact();} + static String text(Map row,String key){Object value=row.get(key);return value==null?null:value.toString();} + static BigDecimal divide(BigDecimal a,int n){return a==null?null:a.divide(BigDecimal.valueOf(n),6,RoundingMode.HALF_UP);} + private static BigDecimal scale(BigDecimal value){return value==null?null:value.setScale(6,RoundingMode.HALF_UP);} + static List windows(Map row,LocalDate until) { + List result=new ArrayList<>(); + for(int n:List.of(7,15,30)) { + String key="w"+n+"_";var formal=decimal(row,key+"outbound");var observed=decimal(row,key+"observed"); + int complete=count(row,key+"complete"),partial=count(row,key+"partial"); + result.add(new Window(n,until.minusDays(n),until,formal,observed,divide(formal,n),complete,partial, + n-complete-partial,formal!=null?"COMPLETE":observed!=null?"PARTIAL":"UNKNOWN", + formal!=null?List.of():List.of("SALES_WINDOW_INCOMPLETE"))); + } + return List.copyOf(result); + } + static Total total(Map row,String field,boolean owner) { + var value=decimal(row,field);int unknown=owner?count(row,field+"_unknown"):value==null?1:0; + return new Total(value,owner?decimal(row,field+"_known"):value,unknown,unknown==0?List.of():List.of("INVENTORY_UNKNOWN_OR_STALE")); + } + private static Risk risk(Map row,String name,Total physical,boolean owner,boolean enabled) { + if(!enabled)return new Risk(null,null,0,null); + int unknown=owner?count(row,name+"_unknown"):row.get(name)==null?1:0; + BigDecimal matched=owner?decimal(row,name+"_count"):BigDecimal.valueOf(count(row,name)); + BigDecimal quantity=owner?decimal(row,name+"_physical"):BigDecimal.ONE.equals(matched)?physical.value():BigDecimal.ZERO; + BigDecimal ratio=unknown==0&&physical.value()!=null&&physical.value().signum()>0&&quantity!=null + ?quantity.divide(physical.value(),6,RoundingMode.HALF_UP):null; + return new Risk(matched,quantity,unknown,ratio); + } + static Row row(Map raw,InventoryTurnoverRequest q,Context context) { + var r=normalized(raw);boolean owner="OWNER".equals(q.group()); + Total physical=total(r,"physical",owner),available=total(r,"available",owner); + var windows=windows(r,context.endDate().plusDays(1));var base=windows.stream().filter(w->w.days()==q.days()).findFirst().orElseThrow(); + var coverage=owner?null:scale(decimal(r,"coverage_days"));var average=scale(decimal(r,"average_physical")); + var turnover=scale(decimal(r,"turnover"));int sampleCount=count(r,"sample_count"); + String coverageStatus=owner?"NOT_APPLICABLE":available.value()==null?"INVENTORY_UNKNOWN_OR_STALE":available.value().signum()==0?"NO_AVAILABLE_STOCK": + base.outboundQuantity()==null||count(r,"unassessable")>0?"UNASSESSABLE":base.outboundQuantity().signum()==0?"NO_OUTBOUND_IN_WINDOW":"VALID"; + String sampleStatus=sampleCount missing=new ArrayList<>();String dates=text(r,"missing_dates"); + if(dates!=null&&!dates.isBlank())for(String date:dates.split(","))missing.add(LocalDate.parse(date.substring(0,10))); + List attention=new ArrayList<>(); + if(count(r,owner?"no_outbound_count":"no_outbound")>0)attention.add("NO_OUTBOUND_30D"); + if(count(r,owner?"slow_count":"slow")>0)attention.add("COVERAGE_GT_THRESHOLD"); + if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK"); + if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK"); + if(count(r,"unassessable")>0)attention.add("UNASSESSABLE"); + String last=text(r,"last_outbound"); + return new Row(text(r,"customer_code"),null,owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available, + total(r,"frozen",owner),total(r,"defective",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO, + windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing), + "VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), + risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable")); + } + static Overview overview(Map raw) { + var r=normalized(raw);int noUnknown=count(r,"no_outbound_unknown");var noKnown=decimal(r,"no_outbound_count"); + return new Overview(total(r,"physical",true),total(r,"available",true), + new Total(noUnknown==0?noKnown:null,noKnown,noUnknown,noUnknown==0?List.of():List.of("SALES_OR_STOCK_UNASSESSABLE")), + new Total(decimal(r,"unassessable"),decimal(r,"unassessable"),0,List.of())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java new file mode 100644 index 000000000..6a3e68eda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.util.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverPolicy.*; + +@Service +@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) +public class InventoryTurnoverQueryService { + private final InventoryTurnoverContext contexts; + private final InventoryTurnoverReadMapper mapper; + public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper){this.contexts=contexts;this.mapper=mapper;} + public Response query(InventoryTurnoverRequest request) { + var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response(); + long total=mapper.count(input); + var rows=request.offset()>=total?List.of():mapper.page(input).stream().map(r->row(r,request,context)).toList(); + Overview summary=null;Charts charts=null; + if(request.overview()) { + summary=overview(mapper.overview(input)); + List owners=new ArrayList<>();int unknown=0; + for(var raw:mapper.owners(input)) { + var r=normalized(raw); + if("META".equals(text(r,"kind")))unknown=count(r,"unknown_count"); + else owners.add(new OwnerPhysical(text(r,"customer_code"),null,decimal(r,"physical"))); + } + charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(input)),context.endDate().plusDays(1))); + } + return new Response(context,summary,charts,rows,total); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java new file mode 100644 index 000000000..c0dac1b28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface InventoryTurnoverReadMapper { + record SampleRun(LocalDate sampleDate,Long runId) {} + @Select("SELECT "+AnalyticsReadMapper.RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} " + +"AND scope_hash=#{scopeHash} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS' " + +"AND published_at_utc<=#{asOf} ORDER BY published_at_utc DESC,id DESC LIMIT 1") + AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); + + // The immutable first-daily marker is set only by publication. Recheck its original schedule evidence. + String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} " + +"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' " + +"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' " + +"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' " + +"AND t.task_role='WAREHOUSE_SYNC' AND r.page_count>=1 AND r.source_total_count>=0 " + +"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc " + +"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " + +"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " + +"AND DATE(DATE_ADD(r.fetch_finished_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "; + @Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r " + +"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE + +"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date") + List samples(@Param("account") String account,@Param("warehouse") String warehouse, + @Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until); + @Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE) + LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("asOf") LocalDateTime asOf); + + @SelectProvider(type=InventoryTurnoverSql.class,method="page") + List> page(@Param("p") InventoryTurnoverSql.Input input); + @SelectProvider(type=InventoryTurnoverSql.class,method="count") + long count(@Param("p") InventoryTurnoverSql.Input input); + @SelectProvider(type=InventoryTurnoverSql.class,method="overview") + Map overview(@Param("p") InventoryTurnoverSql.Input input); + @SelectProvider(type=InventoryTurnoverSql.class,method="owners") + List> owners(@Param("p") InventoryTurnoverSql.Input input); + @SelectProvider(type=InventoryTurnoverSql.class,method="chartWindows") + Map chartWindows(@Param("p") InventoryTurnoverSql.Input input); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java new file mode 100644 index 000000000..a65cdf97a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import java.time.LocalDate; +import java.util.Set; +import org.springframework.format.annotation.DateTimeFormat; + +/** Separate from DETAIL: its dates, windows and filters must never be silently mixed. */ +public record InventoryTurnoverRequest(String sourceAccount, String warehouseCode, + @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays, + String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold, + String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview, + String snapshotContext) { + private static final Set PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays", + "groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext"); + public static void validateParameters(Set keys) { + if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数"); + } + public int days(){return baseWindowDays==null?30:baseWindowDays;} + public String group(){return groupBy==null?"SKU":groupBy;} + public String attention(){return focus==null?"ALL":focus;} + public String sort(){return sortBy==null?"PHYSICAL_DESC":sortBy;} + public int page(){return current==null?1:current;} + public int pageSize(){return size==null?20:size;} + public long offset(){return (long)(page()-1)*pageSize();} + public boolean overview(){return snapshotContext==null||!Boolean.FALSE.equals(includeOverview);} + public LocalDate end(LocalDate today){return endDate==null?today.minusDays(1):endDate;} + public void validate(LocalDate today) { + AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库"); + if(customerCode!=null) AnalyticsQuery.text(customerCode,64,"货主"); + if(sku!=null) AnalyticsQuery.text(sku,128,"SKU"); + if(!Set.of(7,15,30).contains(days())) throw new BizException("基准窗口仅支持7、15、30日"); + if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期"); + if(!Set.of("SKU","OWNER").contains(group())) throw new BizException("分组仅支持SKU或OWNER"); + if("OWNER".equals(group())&&sku!=null) throw new BizException("货主汇总不接受SKU筛选"); + if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT","COVERAGE_LE","FROZEN","DEFECTIVE","UNASSESSABLE").contains(attention())) + throw new BizException("库存关注条件无效"); + if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0 || attention().startsWith("COVERAGE_")&&coverageDaysThreshold==null) + throw new BizException("可售天数阈值须为正整数"); + if(turnoverStatus!=null&&!"INSUFFICIENT".equals(turnoverStatus)) throw new BizException("周转状态无效"); + if(!Set.of("PHYSICAL_DESC","AVAILABLE_DESC","OUTBOUND_7_DESC","OUTBOUND_15_DESC","OUTBOUND_30_DESC", + "COVERAGE_ASC","COVERAGE_DESC","TURNOVER_DESC","NO_OUTBOUND_PHYSICAL_DESC").contains(sort()) + || "OWNER".equals(group())&&sort().startsWith("COVERAGE_") + || "SKU".equals(group())&&"NO_OUTBOUND_PHYSICAL_DESC".equals(sort())) throw new BizException("排序不适用于当前分组"); + if(page()<1||pageSize()<1||pageSize()>100) throw new BizException("分页范围为current≥1,size为1至100"); + if(snapshotContext==null&&page()!=1) throw new BizException("翻页必须携带首屏上下文"); + if(snapshotContext!=null&&(snapshotContext.isBlank()||snapshotContext.length()>16384)) throw new BizException("查询上下文无效,请整体刷新"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java new file mode 100644 index 000000000..0f4086921 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +public final class InventoryTurnoverResponses { + private InventoryTurnoverResponses() {} + public record Window(int days, LocalDate startDate, LocalDate endDateExclusive, BigDecimal outboundQuantity, + BigDecimal observedQuantity, BigDecimal averageDailyOutboundQuantity, int completeDayCount, + int partialDayCount, int unknownDayCount, String dataStatus, List reasonCodes) {} + public record Total(BigDecimal value, BigDecimal knownSubtotal, int unknownSkuCount, List reasonCodes) {} + public record Risk(BigDecimal matchedSkuCount, BigDecimal knownPhysicalQuantity, int unknownSkuCount, BigDecimal physicalQuantityRatio) {} + public record Row(String customerCode,String customerName,String sku,String productName, + Total physicalQuantity,Total availableQuantity,Total frozenQuantity,Total defectiveQuantity, + BigDecimal physicalSkuCount,List windows,BigDecimal averageDailyOutboundQuantity, + BigDecimal inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity, + BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays, + List missingSampleDates,List sampleReasonCodes,LocalDate lastObservedOutboundDate, + List attentionReasons,Risk noOutbound30d,Risk slowCoverage,int unassessableSkuCount) {} + public record Sample(LocalDate date,Long runId) {} + public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate, + int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext, + Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion, + List dailySamples,List missingSampleDates,LocalDate accumulationStartedOn) {} + public record Overview(Total physicalQuantity,Total availableQuantity,Total noOutboundSkuCount,Total unassessableSkuCount) {} + public record OwnerPhysical(String customerCode,String customerName,BigDecimal physicalQuantity) {} + public record Charts(List ownerPhysicalTop,int unknownOwnerCount,List averageDaily) {} + public record Response(Context context,Overview summary,Charts charts,List rows,long total) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java new file mode 100644 index 000000000..e5feb9340 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.*; +import java.util.*; + +/** Every value is bound. Only validated enums select SQL fragments; no source order/remote API reads. */ +public final class InventoryTurnoverSql { + private InventoryTurnoverSql() {} + public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {} + public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh, + boolean salesKnown,List days) { + public LocalDate until(){return days.get(29).date().plusDays(1);} + public LocalDate from(){return days.get(0).date();} + public LocalDate baseFrom(){return until().minusDays(query.days());} + public LocalDate from7(){return until().minusDays(7);} + public LocalDate from15(){return until().minusDays(15);} + public LocalDate from30(){return from();} + public LocalDateTime baseFromUtc(){return baseFrom().atStartOfDay().minusHours(8);} + public LocalDateTime fromUtc(){return from().atStartOfDay().minusHours(8);} + } + static Input input(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen) { + var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales()); + boolean trusted=frozen.snapshot().sourceKnown()&&!frozen.response().analyticsContext().rebuildRequired(); + var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8), + !trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList(); + return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(), + "VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days); + } + private static String scope(String alias) { + return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}" + +" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})"; + } + private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} + private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} + private static String calendar(Input p) { + var parts=new ArrayList(); + for(int i=0;i=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE ") + .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date0 AND evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00') THEN 0 END END observed,") + .append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 AND (quantity IS NOT NULL OR evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00')) THEN 1 ELSE 0 END complete,") + .append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete FROM daily_values v), sku_windows AS (") + .append("SELECT customer_code,sku_code").append(windowAggregates(false)).append(",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") + .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,") + .append("GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates ") + .append("FROM day_result GROUP BY customer_code,sku_code), last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ") + .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); + sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid"); + for(int n:List.of(7,15,30)) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix); + sql.append(",w.sample_count,w.average_physical,w.missing_dates,ls.last_outbound"); + for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field); + sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ") + .append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,") + .append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 AND evidence<=#{p.baseFromUtc} THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,") + .append("CASE WHEN average_physical>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound/average_physical END turnover,") + .append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w30_outbound IS NOT NULL AND evidence<=#{p.fromUtc} THEN CASE WHEN w30_outbound=0 THEN 1 ELSE 0 END END no_outbound,") + .append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL OR evidence IS NULL OR evidence>#{p.baseFromUtc} THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (") + .append("SELECT b.*,CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w") + .append(p.query.days()).append("_outbound=0 AND unassessable=0 THEN 0 END slow FROM sku_calculated b) "); + return sql.toString(); + } + private static String windowAggregates(boolean owner) { + StringBuilder sql=new StringBuilder(); + for(int n:List.of(7,15,30)) { + String range="business_date>=CAST(#{p.from"+n+"} AS DATE)"; + sql.append(",CASE WHEN SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END)=").append(n) + .append(" THEN SUM(CASE WHEN ").append(range).append(" THEN COALESCE(observed,0) END) END w").append(n).append("_outbound") + .append(",SUM(CASE WHEN ").append(range).append(" THEN observed END) w").append(n).append("_observed") + .append(",SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END) w").append(n).append("_complete") + .append(",SUM(CASE WHEN ").append(range).append(" AND complete=0 AND observed IS NOT NULL THEN 1 ELSE 0 END) w").append(n).append("_partial"); + } + return sql.toString(); + } + private static String ownerCte(Input p) { + return ", owner_days AS (SELECT customer_code,business_date,MIN(owner_complete) complete," + +"CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed," + +"CASE WHEN COUNT(sample_physical)=COUNT(*) THEN SUM(sample_physical) END sample_physical FROM day_result GROUP BY customer_code,business_date), owner_windows AS (SELECT customer_code" + +windowAggregates(true)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," + +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical," + +"GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates FROM owner_days GROUP BY customer_code), owner_totals AS (" + +totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.missing_dates," + +"CASE WHEN w.average_physical>0 THEN w.w"+p.query.days()+"_outbound/w.average_physical END turnover," + +String.join(",",List.of(7,15,30).stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList()) + +" FROM owner_totals t JOIN owner_windows w ON w.customer_code=t.customer_code) "; + } + private static String totals(String key,boolean withFocus) { + var sql=new StringBuilder("SELECT ").append(key==null?"COUNT(*) sku_count":key); + for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field) + .append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); + sql.append(",MAX(last_outbound) last_outbound,CASE WHEN COUNT(physical)=COUNT(*) THEN SUM(CASE WHEN physical>0 THEN 1 ELSE 0 END) END physical_skus,COALESCE(SUM(unassessable),0) unassessable"); + for(String risk:List.of("no_outbound","slow")) sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown"); + if(withFocus)sql.append(",MAX(CASE WHEN available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END) fast_count,MAX(CASE WHEN frozen>0 THEN 1 ELSE 0 END) frozen_count,MAX(CASE WHEN defective>0 THEN 1 ELSE 0 END) defective_count"); + return sql.toString(); + } + private static String filter(Input p,boolean owner) { + String predicate=switch(p.query.attention()) { + case "NO_OUTBOUND_30D" -> owner?"no_outbound_count>0":"no_outbound=1"; + case "COVERAGE_GT" -> owner?"slow_count>0":"slow=1"; + case "COVERAGE_LE" -> owner?"fast_count>0":"available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold}"; + case "FROZEN" -> owner?"frozen_count>0":"frozen>0"; + case "DEFECTIVE" -> owner?"defective_count>0":"defective>0"; + case "UNASSESSABLE" -> "unassessable>0"; + default -> "1=1"; + }; + if(p.query.turnoverStatus()!=null) predicate+=" AND sample_count<#{p.query.days}"; + if(!owner&&p.query.sku()!=null) predicate+=" AND sku_code=#{p.query.sku}"; + return " WHERE "+predicate; + } + private static String order(Input p,boolean owner) { + String field=switch(p.query.sort()) { + case "AVAILABLE_DESC" -> "available";case "OUTBOUND_7_DESC" -> "w7_outbound"; + case "OUTBOUND_15_DESC" -> "w15_outbound";case "OUTBOUND_30_DESC" -> "w30_outbound"; + case "COVERAGE_ASC","COVERAGE_DESC" -> "coverage_days";case "TURNOVER_DESC" -> "turnover"; + case "NO_OUTBOUND_PHYSICAL_DESC" -> "no_outbound_physical";default -> "physical"; + }; + return " ORDER BY "+field+" IS NULL,"+field+("COVERAGE_ASC".equals(p.query.sort())?" ASC":" DESC")+(owner?",customer_code":",sku_code,customer_code"); + } + public static String page(Map args) { + Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); + return cte(p)+(owner?ownerCte(p):"")+"SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset}"; + } + public static String count(Map args) { + Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); + return cte(p)+(owner?ownerCte(p):"")+"SELECT COUNT(*) FROM "+(owner?"owner_result":"sku_result")+filter(p,owner); + } + public static String overview(Map args) { + Input p=(Input)args.get("p");return cte(p)+totals(null,false)+" FROM sku_result"; + } + public static String owners(Map args) { + Input p=(Input)args.get("p");return cte(p)+ownerCte(p) + +", ranked_owners AS (SELECT customer_code,physical FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10) " + +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL"; + } + public static String chartWindows(Map args) { + Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT business_date,MIN(owner_complete) complete,CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed FROM day_result GROUP BY business_date) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days"; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java new file mode 100644 index 000000000..1748f175c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.sql.*; +import java.time.*; +import java.util.*; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.reflection.SystemMetaObject; +import org.junit.jupiter.api.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Synthetic local SQL semantics only. Does not assert MySQL performance or deployed availability. */ +class InventoryTurnoverSqlTest { + private Connection db; + private final Configuration config=new Configuration(); + static final LocalDate END=LocalDate.of(2026,10,12); + @BeforeEach void setup() throws Exception { + db=DriverManager.getConnection("jdbc:h2:mem:turnover_"+UUID.randomUUID()+";MODE=MySQL","sa",""); + config.addMapper(InventoryTurnoverReadMapper.class); + execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'"); + execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); + execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)"); + execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)"); + } + @AfterEach void close() throws Exception {db.close();} + static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) { + return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null); + } + static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) { + List days=new ArrayList<>(); + for(int i=0;i<30;i++){LocalDate date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),i>=30-covered?1:0,i> query(String method,InventoryTurnoverSql.Input p) throws Exception { + var args=Map.of("p",p);var bound=config.getMappedStatement(InventoryTurnoverReadMapper.class.getName()+"."+method).getBoundSql(args); + var meta=SystemMetaObject.forObject(args);String sql=bound.getSql(); + for(var parameter:bound.getParameterMappings()) { + Object value=meta.getValue(parameter.getProperty());String literal=value==null?"NULL":value instanceof Boolean b?b?"1":"0":value instanceof Number?value.toString():"'"+value.toString().replace("'","''")+"'"; + sql=sql.replaceFirst("\\?",java.util.regex.Matcher.quoteReplacement(literal)); + } + // H2 inlines repeatedly referenced CTEs exponentially. Materialize each unchanged + // production relation in this local fixture; MySQL execution plans remain unverified. + List relations=new ArrayList<>();int cursor=5; + while(true) { + int as=sql.indexOf(" AS (",cursor);String name=sql.substring(cursor,as).trim();int start=as+5,end=start,depth=1;boolean quoted=false; + while(depth>0){char ch=sql.charAt(end++);if(ch=='\''){if(quoted&&end> rows=new ArrayList<>(); + while(rs.next()){Map row=new HashMap<>();for(int i=1;i<=rs.getMetaData().getColumnCount();i++)row.put(rs.getMetaData().getColumnLabel(i).toLowerCase(Locale.ROOT),rs.getObject(i));rows.add(row);}return rows; + } finally {Collections.reverse(relations);for(String name:relations)execute("DROP TABLE "+name);} + } + private static void decimal(String expected,Object actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(new BigDecimal(actual.toString())));} +} diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index afb1f0ca0..56201850c 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -10,7 +10,7 @@ | 类别 | 正式入口 | 管理边界 | |---|---|---| -| 生产单票发布验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、前置同时核对东莞;订单创建/提交/审核与尾程签收/国内回传分阶段续跑,检查点独立;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | +| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | | Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | | Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 | | Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 | diff --git a/scripts/README.md b/scripts/README.md index 293ad190e..b875922f0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -83,7 +83,7 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ `fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 -头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 +头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 ## 验收证据校验 @@ -157,34 +157,37 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep ## 验收环境变量文件 -### 生产单票发布验收(独立入口) +### 测试/灰度单票验收(独立入口) 称重计算内部模块 `prod-release-measurement-plan.mjs` 统一采用每箱含纸箱、不含托盘 7.5 kg、40×30×30 cm(0.036 m³)。`order-stage` 在载荷绑定及报价前自动按箱数统一申报箱规、货重及总体积;不推算商品净重,已填商品净重若超过含箱货重则阻断。改口径后的已有检查点载荷不一致会停止,不能以换批次方式盲目重跑。 -只读计算命令:`node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs measurement-plan 2`(箱数只允许 1 或 2;执行前仍需生产 HTTP 授权)。外部配置新增 `CHONGCHONG_ACCEPTANCE_PALLET_CODES`,逗号分隔明确允许复用的现有托盘编码;检查所有编码唯一存在于义乌仓,稳定选择列表第一个,读取其实际 `capacityCbm` 和 `weightKg`,不写死容量或皮重。托数按总体积/选中托盘容量向上取整,每托分配整数箱,称重毛重=该托箱数×7.5+皮重;容量不足以容纳完整箱或参数异常时阻断。不创建托盘/库位、不上传称重图片、不确认称重,只向终端输出 `MEASUREMENT_PLAN_ONLY` 计算结果。服务端库位槽数还依赖其默认托容量配置,与此处实际称重托数不是同一字段;后续由服务端分配并回查既有普通位或 A0,不在脚本伪造库位数。 +只读计算命令:`node --env-file="<明确目标环境配置.env>" scripts/prod-release-acceptance.mjs measurement-plan 2`(箱数只允许 1 或 2;执行前仍需目标环境 HTTP 授权)。外部配置新增 `CHONGCHONG_ACCEPTANCE_PALLET_CODES`,逗号分隔明确允许复用的现有托盘编码;检查所有编码唯一存在于义乌仓,稳定选择列表第一个,读取其实际 `capacityCbm` 和 `weightKg`,不写死容量或皮重。托数按总体积/选中托盘容量向上取整,每托分配整数箱,称重毛重=该托箱数×7.5+皮重;容量不足以容纳完整箱或参数异常时阻断。不创建托盘/库位、不上传称重图片、不确认称重,只向终端输出 `MEASUREMENT_PLAN_ONLY` 计算结果。服务端库位槽数还依赖其默认托容量配置,与此处实际称重托数不是同一字段;后续由服务端分配并回查既有普通位或 A0,不在脚本伪造库位数。 离线验证:`node --test scripts/prod-release-measurement-plan.test.mjs`,覆盖小数容量边界、皮重、箱数/重量守恒、错误仓库和托盘白名单;不证明真实称重写入已实现。 `prod-release-acceptance.mjs` 目标固定为同一票从义乌 `CN-YIWU-01` 到洛杉矶 `US-LAX-01`,并在前置检查中同时核对东莞 `CN-DONGGUAN-01`;客户身份由外部配置精确限定,不调用开发/测试全量造数模块。订单阶段负责单票创建、提交和审核;`prod-release-delivery-stage.mjs` 负责既有本批次的派送中、签收文件、派送完成和国内签收事件回查,两个阶段分别使用独立检查点。签收脚本不会借用历史单据或删除数据。 - `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。 -- `node --env-file="$env:USERPROFILE\.codex\env\chongchong-global\prod-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次生产 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。 -- 必填 `CHONGCHONG_ENV=prod`;`CHONGCHONG_ADMIN_API_BASE_URL`、`CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_API_BASE_URL` 分别为同一 HTTPS 前端代理的 `/api`、`/overseas-api`、`/api`。 -- 客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。 +- `node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次目标环境 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。 +- 必填 `CHONGCHONG_ENV=test` 或 `gray`;`CHONGCHONG_ADMIN_API_BASE_URL` 与 `CHONGCHONG_MERCHANT_API_BASE_URL` 必须完全指向同一 HTTPS `/api` 统一入口。拒绝 `prod/dev` 及旧 `CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL` 配置(即使值与统一入口相同也必须移除)。所有业务及文件上传只使用登录返回的 `accessToken`,不读取海外 Token。灰度由运行者显式选择外部灰度配置,不自动读取、修改或回退环境文件。 +- 客户必须为 `ACTIVE` 且箱规初始化为 `READY`;`PROFILE_REQUIRED` 等状态阻断,不自动完善资料或审核客户。客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。 - 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。 -- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-production-order [--resume]`:须另获当次生产写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。 -- 尾程签收入口 `delivery-stage <尾程事实输入.json> <独立签收检查点.json> --confirm-production-delivery`:须另获当次生产写入授权。脚本只接受本批次已核对的排车、出库、派送和尾程订单身份;按状态版本推进 `DELIVERING`,上传验收用签收文件并保存资料,回查 `DELIVERED`、国内入仓 `DELIVERED` 和头程订单 `COMPLETED`。上传或状态写入结果不确定时停止;托盘明细或尾程子状态未同步会记录为阻断项。 -- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。 +- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-single-order [--resume]`:须另获当次目标环境写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。 +- 尾程签收入口 `delivery-stage <尾程事实输入.json> <独立签收检查点.json> --confirm-single-delivery`:须另获当次目标环境写入授权。脚本只接受本批次已核对的排车、出库、派送和尾程订单身份;按状态版本推进 `DELIVERING`,上传验收用签收文件并保存资料,回查 `DELIVERED`、国内入仓 `DELIVERED` 和头程订单 `COMPLETED`。上传或状态写入结果不确定时停止;托盘明细或尾程子状态未同步会记录为阻断项。 +- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境模式及统一 API 地址、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。旧双网关检查点不兼容,遇到绑定不符应先人工核对原票状态,禁止删除检查点后重新造票。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。 - 订单输入字段:`batch`(8~48 位字母数字/连字符)、`origin: {id, code: "CN-YIWU-01"}`、`destination: {id, code, type: "FBA"}`、`payload`(`order` 服务 `OrderRequests.SaveRequest` 完整请求)。客户、环境和操作者由外部 env 强制绑定;`payload.customerName` 必须匹配配置,`customerRemark` 必须为 `RELEASE-ACCEPTANCE:`。显式填 `fulfillmentType=STANDARD`、`transportMode=SEA`、`pickupMethod=SELF_EXPRESS`、`containerMode=LCL`,`cargoes` 一行 1~2 箱;已上传的产品图片 ID 和商品申报字段由本次验收资料提供,箱规/含箱总重/总体积由上述固定口径统一计算,不生成资质或物流签收证明。箱数×每箱数量必须等于总件数,箱规×箱数必须等于总体积。 -- 待实现:供应商及海外资源门禁、测量至签收写入、后续阶段归属核验、跨区等待和检查点、所有阶段真实 HTTP 验证。全链 `execute` / `resume` 命令仍拒绝;订单续跑仅使用上述 `order-stage ... --resume`。不得改用测试全量脚本来代替。 -- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。 +- 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。 +- 全流程内部模块:`prod-release-full-flow.mjs` 负责单票阶段编排和独立子检查点;`prod-release-http.mjs` 负责统一 Gateway、写前请求摘要落盘、失败诊断、合成文件上传及独立登录会话退出;`prod-release-domestic-stage.mjs` 负责国内正常链;`prod-release-overseas-stage.mjs` 负责海外正常链和签收后车次结案。这些文件没有独立CLI,不运行全量fixture,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。 +- 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。 +- 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,其范围仅为 `ONE_ORDER_NORMAL_HTTP_PATH`。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。 +- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs scripts/prod-release-order-stage.test.mjs scripts/prod-release-delivery-stage.test.mjs scripts/prod-release-measurement-plan.test.mjs scripts/prod-release-http.test.mjs scripts/prod-release-domestic-stage.test.mjs scripts/prod-release-overseas-stage.test.mjs scripts/script-documentation-contract.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。最终输出单独报告 `sessionCleanup`,会话退出不受业务请求次数上限影响。 -订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。生产运行示例(只到已审核,执行前确认授权,不代表全链完成): +订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。单票运行示例(只到已审核,执行前确认授权,不代表全链完成): ```powershell -node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order +node --env-file="<明确目标环境配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-single-order # 中断后保持相同输入、账号和检查点;不要删除检查点或换批次。 -node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order --resume +node --env-file="<明确目标环境配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-single-order --resume ``` 统一 TEST 验收账号、密码和 Gateway 由仓库外配置文件显式提供,例如 `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`,不写入仓库。文件提供以下变量: diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index a7d7cf26a..40c625e4a 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -201,7 +201,8 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务 Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' # BEGIN CURRENT TEST REGISTRY CONTRACT function Assert-TestTaskRegistry([string]$registry) { - $current = ($registry -split '(?m)^## 2026-10-04 全流程唯一持续目标与车辆交接(当前)')[0] + # Count this authorized group only; newer independently authorized groups must not be mixed into it. + $current = [regex]::Match($registry, '(?ms)^## 2026-10-04 用户确认的三个独立执行对话(当前派工)\r?\n.*?(?=^## |\z)').Value Assert-Contains $current '## 2026-10-04 用户确认的三个独立执行对话(当前派工)' '当前全流程协作入口' Assert-Contains $current '用户明确选择“按这 3 个独立对话创建”' '测试任务精确数量授权' Assert-Contains $current '所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`' '测试所属总控' diff --git a/scripts/agents-index-registry-contract.test.mjs b/scripts/agents-index-registry-contract.test.mjs index 3e009b82f..4bc9b35a5 100644 --- a/scripts/agents-index-registry-contract.test.mjs +++ b/scripts/agents-index-registry-contract.test.mjs @@ -5,6 +5,8 @@ import test from 'node:test' const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8').replaceAll('\r\n', '\n') const registry = readFileSync(new URL('../../docs/独立任务协作登记.md', import.meta.url), 'utf8') +const currentGroup = registry.match(/^## 2026-10-04 用户确认的三个独立执行对话(当前派工)\r?\n[\s\S]*?(?=^## |$(?![\s\S]))/m)?.[0] +assert.ok(currentGroup, 'mutations must target their own authorized group') const contract = source.split('# BEGIN CURRENT TEST REGISTRY CONTRACT\n')[1]?.split('# END CURRENT TEST REGISTRY CONTRACT')[0] assert.ok(contract, 'test exercises the production PowerShell registry validator') function failures(text) { @@ -17,7 +19,10 @@ function Assert-NotContains([string]$text,[string]$needle,[string]$scope){if($te ${contract} Assert-TestTaskRegistry ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))) ConvertTo-Json -InputObject @($script:failures) -Compress` - return JSON.parse(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', command], { encoding: 'utf8' })) + // Large registries exceed Windows command-line limits; the generated harness is passed over stdin. + return JSON.parse(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', + '$registryHarness = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd())); & ([ScriptBlock]::Create($registryHarness))'], + { encoding: 'utf8', input: Buffer.from(command).toString('base64') })) } test('actual explicitly authorized TEST registry passes the production validator', () => { assert.deepEqual(failures(registry), []) @@ -32,4 +37,5 @@ for (const [name, mutate] of [ ['obsolete empty status', s => s + '\n当前无登记任务'], ['foreign project identity', s => s + '\n6f874ec3-d3b2-49bd-8625-327f4d685f98'], ['retired controller', s => s + '\n01a0194a-259c-7171-8967-074731a27618'], -]) test(`production validator rejects ${name}`, () => assert.ok(failures(mutate(registry)).length > 0)) +]) test(`production validator rejects ${name}`, () => + assert.ok(failures(registry.replace(currentGroup, mutate(currentGroup))).length > 0)) From 16d0b9c80fd8ee627407e306418b4e42d2320f3d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 01:51:51 +0800 Subject: [PATCH 61/80] fix: finalize inventory turnover queries and reuse snapshot aggregation --- .../query/InventoryTurnoverContext.java | 8 +- .../query/InventoryTurnoverPolicy.java | 7 +- .../query/InventoryTurnoverQueryService.java | 36 ++-- .../query/InventoryTurnoverReadMapper.java | 12 +- .../analytics/query/InventoryTurnoverSql.java | 49 ++++- .../self-operated-inventory-turnover-v1.yaml | 195 ++++++++++++++++++ ...atformInventoryTurnoverControllerTest.java | 52 +++++ .../query/InventoryTurnoverContextTest.java | 102 +++++++++ .../query/InventoryTurnoverRequestTest.java | 31 +++ .../query/InventoryTurnoverSqlTest.java | 63 ++++++ 10 files changed, 527 insertions(+), 28 deletions(-) create mode 100644 lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java index a8a0dfe92..a82f60708 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java @@ -42,13 +42,15 @@ public class InventoryTurnoverContext { sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null); + String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null); var mapping=new TreeMap(); - for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),utc(asOf),end.minusDays(29),end.plusDays(1))) { - if(sample.sampleDate()==null||sample.runId()==null||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); + for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(29),end.plusDays(1))) { + if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(29))||sample.sampleDate().isAfter(end) + ||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); } List samples=new ArrayList<>(); for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); - var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),utc(asOf)); + var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)); var c=snapshot.context(); var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(), c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java index 9f377c52e..505b6906d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java @@ -69,8 +69,13 @@ public final class InventoryTurnoverPolicy { "VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable")); } - static Overview overview(Map raw) { + static Overview overview(Map raw,Context context) { var r=normalized(raw);int noUnknown=count(r,"no_outbound_unknown");var noKnown=decimal(r,"no_outbound_count"); + if(count(r,"sku_count")==0&&!"VALID".equals(context.analyticsContext().inventoryStatus())) { + var unknown=new Total(null,null,0,List.of("INVENTORY_UNKNOWN_OR_STALE")); + return new Overview(unknown,unknown,new Total(null,null,0,List.of("SALES_OR_STOCK_UNASSESSABLE")), + new Total(null,null,0,List.of("SALES_OR_STOCK_UNASSESSABLE"))); + } return new Overview(total(r,"physical",true),total(r,"available",true), new Total(noUnknown==0?noKnown:null,noKnown,noUnknown,noUnknown==0?List.of():List.of("SALES_OR_STOCK_UNASSESSABLE")), new Total(decimal(r,"unassessable"),decimal(r,"unassessable"),0,List.of())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java index 6a3e68eda..5cbfe4ca8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java @@ -1,6 +1,9 @@ package com.baiyue.lastleg.selfoperated.analytics.query; import java.util.*; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @@ -12,22 +15,31 @@ import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverP public class InventoryTurnoverQueryService { private final InventoryTurnoverContext contexts; private final InventoryTurnoverReadMapper mapper; - public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper){this.contexts=contexts;this.mapper=mapper;} + private final ObjectMapper json; + public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper,ObjectMapper json){this.contexts=contexts;this.mapper=mapper;this.json=json;} public Response query(InventoryTurnoverRequest request) { var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response(); - long total=mapper.count(input); - var rows=request.offset()>=total?List.of():mapper.page(input).stream().map(r->row(r,request,context)).toList(); - Overview summary=null;Charts charts=null; - if(request.overview()) { - summary=overview(mapper.overview(input)); - List owners=new ArrayList<>();int unknown=0; - for(var raw:mapper.owners(input)) { - var r=normalized(raw); - if("META".equals(text(r,"kind")))unknown=count(r,"unknown_count"); - else owners.add(new OwnerPhysical(text(r,"customer_code"),null,decimal(r,"physical"))); + long total=0;boolean hasTotal=false;Overview summary=null;Charts charts=null; + List rows=new ArrayList<>();List owners=new ArrayList<>();int unknown=0; + for(var part:mapper.report(input)) { + var envelope=normalized(part);Map value; + try { + value=json.readerFor(new TypeReference>(){}).with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .readValue(text(envelope,"payload")); + } catch(Exception e){throw new IllegalStateException("Invalid turnover query projection",e);} + switch(text(envelope,"section")) { + case "ROW" -> rows.add(row(value,request,context)); + case "TOTAL" -> {total=decimal(value,"total").longValueExact();hasTotal=true;} + case "OVERVIEW" -> summary=overview(value,context); + case "OWNER" -> owners.add(new OwnerPhysical(text(value,"customer_code"),null,decimal(value,"physical"))); + case "OWNER_META" -> unknown=count(value,"unknown_count"); + default -> throw new IllegalStateException("Unknown turnover query section"); } + } + if(!hasTotal||request.overview()&&summary==null)throw new IllegalStateException("Missing turnover query section"); + if(request.overview()) { charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(input)),context.endDate().plusDays(1))); } - return new Response(context,summary,charts,rows,total); + return new Response(context,summary,charts,List.copyOf(rows),total); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java index c0dac1b28..cf4fff1b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java @@ -15,11 +15,12 @@ public interface InventoryTurnoverReadMapper { AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); // The immutable first-daily marker is set only by publication. Recheck its original schedule evidence. - String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} " + String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} " +"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' " +"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' " +"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' " - +"AND t.task_role='WAREHOUSE_SYNC' AND r.page_count>=1 AND r.source_total_count>=0 " + +"AND t.task_role='WAREHOUSE_SYNC' AND t.scope_hash=r.scope_hash " + +"AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code AND r.page_count>=1 AND r.source_total_count>=0 " +"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc " +"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " +"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " @@ -27,10 +28,10 @@ public interface InventoryTurnoverReadMapper { @Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r " +"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE +"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date") - List samples(@Param("account") String account,@Param("warehouse") String warehouse, + List samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash, @Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until); @Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE) - LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("asOf") LocalDateTime asOf); + LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); @SelectProvider(type=InventoryTurnoverSql.class,method="page") List> page(@Param("p") InventoryTurnoverSql.Input input); @@ -42,4 +43,7 @@ public interface InventoryTurnoverReadMapper { List> owners(@Param("p") InventoryTurnoverSql.Input input); @SelectProvider(type=InventoryTurnoverSql.class,method="chartWindows") Map chartWindows(@Param("p") InventoryTurnoverSql.Input input); + + @SelectProvider(type=InventoryTurnoverSql.class,method="report") + List> report(@Param("p") InventoryTurnoverSql.Input input); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index e5feb9340..665388449 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -30,7 +30,7 @@ public final class InventoryTurnoverSql { return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}" +" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})"; } - private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} + private static String valid(String field){return "product_quality IN (0,1) AND "+field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} private static String calendar(Input p) { var parts=new ArrayList(); @@ -50,7 +50,8 @@ public final class InventoryTurnoverSql { .append("), stocks AS (").append(stocks).append("), sales_days AS (SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE ") .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date0 AND evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00') THEN 0 END END observed,") .append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 AND (quantity IS NOT NULL OR evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00')) THEN 1 ELSE 0 END complete,") - .append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete FROM daily_values v), sku_windows AS (") + .append("CASE WHEN #{p.salesKnown}=1 AND coverage=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete,") + .append("CASE WHEN #{p.salesKnown}=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 THEN 0 END END owner_observed FROM daily_values v), sku_windows AS (") .append("SELECT customer_code,sku_code").append(windowAggregates(false)).append(",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,") .append("GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates ") @@ -94,7 +96,7 @@ public final class InventoryTurnoverSql { } private static String ownerCte(Input p) { return ", owner_days AS (SELECT customer_code,business_date,MIN(owner_complete) complete," - +"CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed," + +"SUM(owner_observed) observed," +"CASE WHEN COUNT(sample_physical)=COUNT(*) THEN SUM(sample_physical) END sample_physical FROM day_result GROUP BY customer_code,business_date), owner_windows AS (SELECT customer_code" +windowAggregates(true)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical," @@ -107,9 +109,9 @@ public final class InventoryTurnoverSql { private static String totals(String key,boolean withFocus) { var sql=new StringBuilder("SELECT ").append(key==null?"COUNT(*) sku_count":key); for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field) - .append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); + .append(",CASE WHEN COUNT(*)=0 THEN 0 ELSE SUM(").append(field).append(") END ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); sql.append(",MAX(last_outbound) last_outbound,CASE WHEN COUNT(physical)=COUNT(*) THEN SUM(CASE WHEN physical>0 THEN 1 ELSE 0 END) END physical_skus,COALESCE(SUM(unassessable),0) unassessable"); - for(String risk:List.of("no_outbound","slow")) sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown"); + for(String risk:List.of("no_outbound","slow")) sql.append(",COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END),0) ").append(risk).append("_count,COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END),0) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown"); if(withFocus)sql.append(",MAX(CASE WHEN available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END) fast_count,MAX(CASE WHEN frozen>0 THEN 1 ELSE 0 END) frozen_count,MAX(CASE WHEN defective>0 THEN 1 ELSE 0 END) defective_count"); return sql.toString(); } @@ -150,9 +152,40 @@ public final class InventoryTurnoverSql { public static String owners(Map args) { Input p=(Input)args.get("p");return cte(p)+ownerCte(p) +", ranked_owners AS (SELECT customer_code,physical FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10) " - +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL"; + +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code"; } public static String chartWindows(Map args) { - Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT business_date,MIN(owner_complete) complete,CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed FROM day_result GROUP BY business_date) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days"; + Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT d.business_date," + +"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete," + +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed " + +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days"; + } + private static String jsonFields(List fields) { + return "JSON_OBJECT("+String.join(",",fields.stream().map(f->"'"+f+"',"+f).toList())+")"; + } + /** One statement shares the materialized stock/day/window relations across all first-page sections. */ + public static String report(Map args) { + Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview(); + String source=owner?"owner_result":"sku_result"; + List fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable")); + for(int n:List.of(7,15,30))for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix); + if(owner) { + fields.add("physical_skus"); + for(String field:List.of("physical","available","frozen","defective"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix); + for(String risk:List.of("no_outbound","slow"))for(String suffix:List.of("count","physical","unknown"))fields.add(risk+"_"+suffix); + } else fields.addAll(List.of("sku_code","product_name","coverage_days","no_outbound","slow")); + String sql=cte(p)+(owner||overview?ownerCte(p):"") + +", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"; + if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)"; + sql+=" SELECT 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_result" + +" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner); + if(overview) { + List summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown")); + for(String field:List.of("physical","available")){summary.add(field);summary.add(field+"_known");summary.add(field+"_unknown");} + sql+=" UNION ALL SELECT 'OVERVIEW' section,0 ordinal,"+jsonFields(summary)+" payload FROM summary_result" + +" UNION ALL SELECT 'OWNER' section,ordinal,JSON_OBJECT('customer_code',customer_code,'physical',physical) payload FROM top_owners" + +" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_result WHERE physical IS NULL"; + } + return sql+" ORDER BY section,ordinal"; } } diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml new file mode 100644 index 000000000..386d0c90c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -0,0 +1,195 @@ +openapi: 3.0.3 +info: + title: 平台自营库存周转只读查询 + version: 1.0.0 + description: >- + 仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。 + 仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。 + 三窗为截至endDate的7/15/30个完整北京时间自然日;当前库存独立固定版本。 + 每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据, + 不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、 + 重建状态及全部30日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。 + 历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。 + 上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。 + 历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。 +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/self-operated-analytics/inventory-turnover: + get: + operationId: inventoryTurnover + summary: 单仓SKU或货主三窗口库存周转 + description: >- + 精确customerCode限定列表与概览;sku、focus及turnoverStatus只筛列表。 + 按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。 + OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。 + 首屏必须current=1且返回概览;后续携带snapshotContext,includeOverview=false省略概览。 + 不接受attention、salesWindowDays、版本ID或其他视图参数。 + parameters: + - {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + - {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + - {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}} + - {name: baseWindowDays, in: query, schema: {type: integer, enum: [7, 15, 30], default: 30}} + - {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}} + - {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} + - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} + - name: focus + in: query + schema: {type: string, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL} + - {name: coverageDaysThreshold, in: query, description: 'GT/LE必填,其余可选;无默认业务阈值', schema: {type: integer, minimum: 1}} + - {name: turnoverStatus, in: query, schema: {type: string, enum: [INSUFFICIENT]}} + - name: sortBy + in: query + description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。使用正式值及未舍入值排序。' + schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_DESC, AVAILABLE_DESC, OUTBOUND_7_DESC, OUTBOUND_15_DESC, OUTBOUND_30_DESC, COVERAGE_ASC, COVERAGE_DESC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]} + - {name: current, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + - {name: includeOverview, in: query, schema: {type: boolean, default: true}} + - {name: snapshotContext, in: query, description: 首屏返回的完整不可拆分上下文;翻页必填, schema: {type: string, minLength: 1, maxLength: 16384}} + responses: + '200': + description: 统一ApiResponse;参数或上下文业务错误沿现有异常响应,不伪装成空结果 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Response'} + '400': {description: HTTP参数类型或日期格式不合法} + '401': {description: 无可信登录身份} + '403': {description: 非平台、代登录或缺少独立只读权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + Quantity: + type: number + nullable: true + description: PCS件数或对应指标值;未知为null,派生值六位小数,不把未知当0 + Reasons: + type: array + items: {type: string} + Total: + type: object + required: [value, knownSubtotal, unknownSkuCount, reasonCodes] + properties: + value: {$ref: '#/components/schemas/Quantity'} + knownSubtotal: {$ref: '#/components/schemas/Quantity'} + unknownSkuCount: {type: integer, minimum: 0} + reasonCodes: {$ref: '#/components/schemas/Reasons'} + Risk: + type: object + description: 命中为已确认小计;只有整个实物及风险判断完整、分母大于0时才提供件数比例。未设慢消化阈值则数量与比例均null。 + required: [matchedSkuCount, knownPhysicalQuantity, unknownSkuCount, physicalQuantityRatio] + properties: + matchedSkuCount: {$ref: '#/components/schemas/Quantity'} + knownPhysicalQuantity: {$ref: '#/components/schemas/Quantity'} + unknownSkuCount: {type: integer, minimum: 0} + physicalQuantityRatio: {$ref: '#/components/schemas/Quantity'} + Window: + type: object + required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes] + properties: + days: {type: integer, enum: [7, 15, 30]} + startDate: {type: string, format: date} + endDateExclusive: {type: string, format: date} + outboundQuantity: {$ref: '#/components/schemas/Quantity'} + observedQuantity: {$ref: '#/components/schemas/Quantity'} + averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'} + completeDayCount: {type: integer, minimum: 0} + partialDayCount: {type: integer, minimum: 0} + unknownDayCount: {type: integer, minimum: 0} + dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]} + reasonCodes: {$ref: '#/components/schemas/Reasons'} + Context: + type: object + required: [snapshotContext, sourceAccount, warehouseCode, endDate, baseWindowDays, queryAsOf, queryExpiresAt, analyticsContext, inventoryObservedFrom, inventoryObservedUntil, sampleRuleVersion, dailySamples, missingSampleDates, accumulationStartedOn] + properties: + snapshotContext: {type: string} + sourceAccount: {type: string} + warehouseCode: {type: string} + endDate: {type: string, format: date} + baseWindowDays: {type: integer, enum: [7, 15, 30]} + queryAsOf: {type: string, format: date-time} + queryExpiresAt: {type: string, format: date-time} + analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'} + inventoryObservedFrom: {type: string, format: date-time, nullable: true} + inventoryObservedUntil: {type: string, format: date-time, nullable: true} + sampleRuleVersion: {type: string, enum: [DAILY_SCHEDULED_FIRST_V1]} + dailySamples: + type: array + minItems: 30 + maxItems: 30 + items: + type: object + required: [date, runId] + properties: + date: {type: string, format: date} + runId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号} + missingSampleDates: {type: array, items: {type: string, format: date}} + accumulationStartedOn: {type: string, format: date, nullable: true} + Row: + type: object + required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutbound30d, slowCoverage, unassessableSkuCount] + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + sku: {type: string, nullable: true} + productName: {type: string, nullable: true} + physicalQuantity: {$ref: '#/components/schemas/Total'} + availableQuantity: {$ref: '#/components/schemas/Total'} + frozenQuantity: {$ref: '#/components/schemas/Total'} + defectiveQuantity: {$ref: '#/components/schemas/Total'} + physicalSkuCount: {$ref: '#/components/schemas/Quantity'} + windows: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}} + averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'} + inventoryCoverageDays: {$ref: '#/components/schemas/Quantity'} + coverageStatus: {type: string, enum: [VALID, NOT_APPLICABLE, INVENTORY_UNKNOWN_OR_STALE, NO_AVAILABLE_STOCK, UNASSESSABLE, NO_OUTBOUND_IN_WINDOW]} + averagePhysicalQuantity: {$ref: '#/components/schemas/Quantity'} + quantityTurnoverCount: {$ref: '#/components/schemas/Quantity'} + turnoverStatus: {type: string, enum: [VALID, INSUFFICIENT, ZERO_AVERAGE_STOCK, SALES_WINDOW_UNAVAILABLE]} + qualifiedSampleDays: {type: integer, minimum: 0, maximum: 30} + requiredSampleDays: {type: integer, enum: [7, 15, 30]} + missingSampleDates: {type: array, items: {type: string, format: date}} + sampleReasonCodes: {$ref: '#/components/schemas/Reasons'} + lastObservedOutboundDate: {type: string, format: date, nullable: true} + attentionReasons: {$ref: '#/components/schemas/Reasons'} + noOutbound30d: {$ref: '#/components/schemas/Risk'} + slowCoverage: {$ref: '#/components/schemas/Risk'} + unassessableSkuCount: {type: integer, minimum: 0} + Overview: + type: object + nullable: true + required: [physicalQuantity, availableQuantity, noOutboundSkuCount, unassessableSkuCount] + properties: + physicalQuantity: {$ref: '#/components/schemas/Total'} + availableQuantity: {$ref: '#/components/schemas/Total'} + noOutboundSkuCount: {$ref: '#/components/schemas/Total'} + unassessableSkuCount: {$ref: '#/components/schemas/Total'} + Charts: + type: object + nullable: true + required: [ownerPhysicalTop, unknownOwnerCount, averageDaily] + properties: + ownerPhysicalTop: + type: array + maxItems: 10 + items: + type: object + required: [customerCode, customerName, physicalQuantity] + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + physicalQuantity: {$ref: '#/components/schemas/Quantity'} + unknownOwnerCount: {type: integer, minimum: 0} + averageDaily: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}} + Response: + type: object + required: [context, summary, charts, rows, total] + properties: + context: {$ref: '#/components/schemas/Context'} + summary: {$ref: '#/components/schemas/Overview'} + charts: {$ref: '#/components/schemas/Charts'} + rows: {type: array, maxItems: 100, items: {$ref: '#/components/schemas/Row'}} + total: {type: string, pattern: '^[0-9]+$', description: 服务统一将Long序列化为字符串} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java new file mode 100644 index 000000000..bf9bc058a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.query.*; +import java.time.LocalDate; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.core.MethodParameter; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.*; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformInventoryTurnoverControllerTest { + private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));} + @Test void enforcesIndependentPlatformReadPermissionAndStrictParameterNames() { + var service=mock(InventoryTurnoverQueryService.class);var controller=new PlatformInventoryTurnoverController(service); + var customer=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION)); + var writer=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); + assertThrows(ResponseStatusException.class,()->controller.query(null,null,Map.of())); + assertThrows(ResponseStatusException.class,()->controller.query(customer,null,Map.of())); + assertThrows(ResponseStatusException.class,()->controller.query(writer,null,Map.of())); + assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("attention","ALL"))); + assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("salesRunId","11"))); + verifyNoInteractions(service); + assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformInventoryTurnoverController.class.getAnnotation(PreAuthorize.class).value()); + } + @Test void httpBindsFrozenTurnoverContractWithoutTouchingCollection() throws Exception { + var service=mock(InventoryTurnoverQueryService.class); + var identity=new HandlerMethodArgumentResolver(){ + public boolean supportsParameter(MethodParameter p){return p.getParameterType()==AuthenticatedUser.class;} + public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory b){return platform();} + }; + var mvc=MockMvcBuilders.standaloneSetup(new PlatformInventoryTurnoverController(service)).setCustomArgumentResolvers(identity).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover") + .param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12") + .param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT") + .param("coverageDaysThreshold","60").param("current","2").param("size","20") + .param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk()); + var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture()); + var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days()); + assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertEquals("fixed-token",q.snapshotContext()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java new file mode 100644 index 000000000..594492337 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InventoryTurnoverContextTest { + private static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z"); + private static final LocalDate END=LocalDate.of(2026,10,12); + private static final String SH="a".repeat(64),IH="b".repeat(64); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private AnalyticsReadMapper analytics; + private InventoryTurnoverReadMapper mapper; + private InventoryTurnoverContext contexts; + @BeforeEach void setup() { + analytics=mock(AnalyticsReadMapper.class);mapper=mock(InventoryTurnoverReadMapper.class); + contexts=at(NOW); + when(analytics.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + for(var run:List.of(run(11,1,SH),run(22,2,IH))) { + when(analytics.publishedRun(run.id())).thenReturn(run); + when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run); + } + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.SampleRun(END,100L))); + when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END); + } + @Test void sameContextFreezesAllThirtyDatesAndMissingSamples() { + var first=contexts.resolve(request(null)).response(); + assertEquals(30,first.dailySamples().size());assertEquals(29,first.missingSampleDates().size()); + assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate()); + assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive()); + assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response()); + verify(mapper,times(2)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); + // A subsequently published sample cannot appear in the old asOf query. If reconstruction + // ever differs, reject the context instead of mixing old charts and new rows. + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); + assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); + } + @Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception { + String token=contexts.resolve(request(null)).response().snapshotContext(); + var evidence=json.readTree(Base64.getUrlDecoder().decode(token)); + ((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L); + String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence)); + assertThrows(BizException.class,()->contexts.resolve(request(tampered))); + var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token); + assertThrows(BizException.class,()->contexts.resolve(another)); + assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token))); + assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token))); + assertThrows(BizException.class,()->contexts.resolve(request("not-a-token"))); + } + @Test void absentVersionsStayAbsentAndUnconfiguredWarehouseIsRejected() { + when(mapper.versionAt(anyLong(),anyString(),any())).thenReturn(null); + var c=contexts.resolve(request(null)).response(); + assertEquals(0L,c.analyticsContext().salesRunId());assertEquals(0L,c.analyticsContext().inventoryRunId()); + assertEquals("UNKNOWN",c.analyticsContext().inventoryStatus()); + assertEquals(c,contexts.resolve(request(c.snapshotContext())).response()); + when(analytics.scopes("demo","W")).thenReturn(List.of()); + assertThrows(BizException.class,()->contexts.resolve(request(null))); + } + @Test void rejectsDuplicateRepresentativesAndScopeDrift() { + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of( + new InventoryTurnoverReadMapper.SampleRun(END,100L),new InventoryTurnoverReadMapper.SampleRun(END,101L))); + assertThrows(BizException.class,()->contexts.resolve(request(null))); + when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH)); + assertThrows(BizException.class,()->contexts.resolve(request(null))); + } + @Test void localHistoryCoverageSurvivesAgeButRebuildDoesNotAuthorizeFormalSales() { + var old=at(NOW.plusSeconds(200000)).resolve(request(null)); + assertEquals("STALE",old.response().analyticsContext().inventoryStatus()); + assertTrue(InventoryTurnoverSql.input(request(null),old).days().stream().allMatch(d->d.coverage()==1)); + when(analytics.applyBarriers("demo","W")).thenReturn(1L); + var rebuilding=contexts.resolve(request(null)); + assertTrue(InventoryTurnoverSql.input(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==0)); + } + @Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() { + var service=new InventoryTurnoverQueryService(contexts,mapper,json); + when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"), + Map.of("section","OVERVIEW","payload","{\"sku_count\":0,\"physical\":0,\"available\":0,\"no_outbound_count\":0,\"unassessable\":0}"))); + var first=service.query(request(null));assertEquals(0,first.total());assertNotNull(first.summary());assertNotNull(first.charts()); + verify(mapper).report(any());verify(mapper).chartWindows(any()); + verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any()); + clearInvocations(mapper); + when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"))); + var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext()); + var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any()); + } + private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC));} + static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);} + private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} + private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);} + private static Run run(long id,long scope,String hash){ + return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash, + "{\"businessTimezone\":\"Asia/Shanghai\",\"sourceTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600}","PUBLISHED","COMPLETE", + END.minusDays(29).atStartOfDay().minusHours(8),END.plusDays(1).atStartOfDay().minusHours(8),END.minusDays(29),END.plusDays(1), + utc(NOW.minusSeconds(3660)),utc(NOW.minusSeconds(3600)),utc(NOW.minusSeconds(3500)),1L,null,null,null,"BUSINESS",true); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java new file mode 100644 index 000000000..477c10cf1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class InventoryTurnoverRequestTest { + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private static final LocalDate TODAY=LocalDate.of(2026,10,13); + private InventoryTurnoverRequest request(Map override) { + Map values=new HashMap<>();values.put("sourceAccount","demo");values.put("warehouseCode","W");values.putAll(override); + return json.convertValue(values,InventoryTurnoverRequest.class); + } + @Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() { + var q=request(Map.of("includeOverview",false));q.validate(TODAY); + assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview()); + } + @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { + for(var invalid:List.of(Map.of("endDate","2026-10-13"),Map.of("baseWindowDays",10), + Map.of("groupBy","OWNER","sku","S"),Map.of("groupBy","OWNER","sortBy","COVERAGE_DESC"), + Map.of("sortBy","NO_OUTBOUND_PHYSICAL_DESC"),Map.of("current",2), + Map.of("size",101),Map.of("coverageDaysThreshold",0),Map.of("focus","COVERAGE_GT"), + Map.of("snapshotContext"," "),Map.of("turnoverStatus","VALID"))) { + assertThrows(BizException.class,()->request(invalid).validate(TODAY),invalid.toString()); + } + request(Map.of("groupBy","OWNER","sortBy","NO_OUTBOUND_PHYSICAL_DESC","current",2,"snapshotContext","evidence")).validate(TODAY); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index 1748f175c..13018c274 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -74,6 +74,69 @@ class InventoryTurnoverSqlTest { var row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),7,0,true)).get(0); decimal("0",row.get("coverage_days"));decimal("1",row.get("unassessable")); } + @Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception { + seed("A","SKU",30); + execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); + var row=query("page",p).get(0); + decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days")); + decimal("29",row.get("sample_count"));assertNull(row.get("turnover")); + execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=11"); + assertNull(query("page",p).get(0).get("w30_outbound")); + var owner=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); + decimal("300",owner.get("w30_outbound")); + } + @Test void zeroSalesAndZeroAverageHaveDifferentTurnoverResults() throws Exception { + seed("A","SKU",30); + execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); + var row=query("page",p).get(0); + decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days")); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); + row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover")); + } + @Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception { + var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20); + var covered=query("chartWindows",input(q,30,0,true)).get(0); + decimal("0",covered.get("w30_outbound"));decimal("30",covered.get("w30_complete")); + var absent=query("chartWindows",input(q,0,0,false)).get(0); + assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed")); + decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count")); + } + @Test void fullScopeFilterPrecedesPaginationAndKeepsSoldOutHistoricalCandidates() throws Exception { + seed("A","LOW",30);seed("A","HIGH",30);seed("B","SOLD",30); + execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=3000,physical_quantity=3000 WHERE sku_code='HIGH' AND snapshot_run_id=22 AND product_quality=0"); + execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0,physical_quantity=0 WHERE sku_code='SOLD' AND snapshot_run_id=22"); + var p=input(request("SKU","COVERAGE_GT","COVERAGE_DESC",null,100,1,1),30,30,true); + var selected=query("page",p);assertEquals(1,selected.size());assertEquals("HIGH",selected.get(0).get("sku_code")); + decimal("1",query("count",p).get(0).values().iterator().next()); + decimal("3",query("overview",p).get(0).get("sku_count")); + var owners=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); + var sold=owners.stream().filter(r->"B".equals(r.get("customer_code"))).findFirst().orElseThrow(); + decimal("0",sold.get("physical"));decimal("300",sold.get("w30_outbound"));decimal("0.6",sold.get("turnover")); + } + @Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception { + seed("A","SKU",30); + for(int days:List.of(7,15)) { + var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,null,1,20,true,null); + var row=query("page",input(q,30,30,true)).get(0); + decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound")); + decimal("60",row.get("coverage_days"));decimal(days==7?"0.14":"0.3",row.get("turnover"));decimal(String.valueOf(days),row.get("sample_count")); + } + } + @Test void sharedReportMatchesStandaloneValuesAndKeepsPageOrder() throws Exception { + seed("A","SKU",30);seed("B","SKU",30); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=900 WHERE customer_code='B' AND snapshot_run_id=22 AND product_quality=0"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true); + var report=query("report",p);var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var page=report.stream().filter(r->"ROW".equals(r.get("section"))).toList();assertEquals(1,page.size()); + Object raw=page.get(0).get("payload");var row=raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString()); + assertEquals("B",row.path("customer_code").asText());decimal("300",row.path("w30_outbound").decimalValue());decimal("0.6",row.path("turnover").decimalValue()); + var total=report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow().get("payload"); + var count=total instanceof byte[] bytes?json.readTree(bytes):json.readTree(total.toString());assertEquals(2,count.path("total").asInt()); + assertEquals(2,report.stream().filter(r->"OWNER".equals(r.get("section"))).count()); + assertEquals(1,report.stream().filter(r->"OVERVIEW".equals(r.get("section"))).count()); + } private void seed(String owner,String sku,int samples) throws Exception { profile(11,owner,sku);profile(22,owner,sku);stock(22,owner,sku,0,600,600);stock(22,owner,sku,1,10,0); for(int i=0;i<30;i++) { From 765b6e6208581006ae2e09dd672368aa3809d5b4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 03:36:54 +0800 Subject: [PATCH 62/80] perf(lastleg): reduce inventory turnover query aggregation work --- .../analytics/query/InventoryTurnoverSql.java | 144 ++++++++--- .../query/InventoryTurnoverContextTest.java | 7 +- .../query/InventoryTurnoverSqlTest.java | 239 ++++++++++++++++++ 3 files changed, 353 insertions(+), 37 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index 665388449..cc7fdb683 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -38,39 +38,38 @@ public final class InventoryTurnoverSql { +"CAST(#{p.days["+i+"].coverage} AS SIGNED) coverage,CAST(#{p.days["+i+"].sampleRun,jdbcType=BIGINT} AS SIGNED) sample_run"); return String.join(" UNION ALL ",parts); } + private static String sampleRuns(Input p) { + // The frozen context already resolved these versions. Bind them directly instead + // of repeatedly expanding the 30-day calendar as a membership subquery. + var runs=new ArrayList(); + for(int i=0;i=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE " + +scope("")+" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE ") - .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date0 AND evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00') THEN 0 END END observed,") - .append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 AND (quantity IS NOT NULL OR evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00')) THEN 1 ELSE 0 END complete,") - .append("CASE WHEN #{p.salesKnown}=1 AND coverage=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete,") - .append("CASE WHEN #{p.salesKnown}=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 THEN 0 END END owner_observed FROM daily_values v), sku_windows AS (") - .append("SELECT customer_code,sku_code").append(windowAggregates(false)).append(",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") - .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,") - .append("GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates ") - .append("FROM day_result GROUP BY customer_code,sku_code), last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ") + .append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)") + .append(skuWindows()).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ") .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid"); for(int n:List.of(7,15,30)) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix); - sql.append(",w.sample_count,w.average_physical,w.missing_dates,ls.last_outbound"); + sql.append(",w.sample_count,w.average_physical,w.sample_mask,ls.last_outbound"); for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field); sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ") .append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,") @@ -82,26 +81,95 @@ public final class InventoryTurnoverSql { .append(p.query.days()).append("_outbound=0 AND unassessable=0 THEN 0 END slow FROM sku_calculated b) "); return sql.toString(); } - private static String windowAggregates(boolean owner) { + private static String skuWindows() { + // One shared suffix table qualifies absent sales dates. Millisecond evidence + // after a midnight selects the next midnight, never the truncated same date. + var sql=new StringBuilder(", evidence_boundaries AS (SELECT start_utc,LAG(start_utc) OVER (ORDER BY start_utc) previous_start"); + for(int n:List.of(7,15,30)) for(String kind:List.of("complete","known","partial")) { + String coverage=switch(kind){case "complete"->"coverage=1";case "known"->"coverage>0";default->"coverage=2";}; + sql.append(",SUM(CASE WHEN business_date>=CAST(#{p.from").append(n).append("} AS DATE) AND ").append(coverage) + .append(" THEN 1 ELSE 0 END) OVER (ORDER BY start_utc ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) w").append(n).append('_').append(kind); + } + sql.append(" FROM calendar), sku_zero_counts AS (SELECT c.customer_code,c.sku_code"); + for(int n:List.of(7,15,30)) for(String kind:List.of("complete","known","partial")) + sql.append(",CASE WHEN c.unit_valid=1").append("complete".equals(kind)?"":" AND #{p.salesKnown}=1") + .append(" THEN COALESCE(b.w").append(n).append('_').append(kind).append(",0) ELSE 0 END w").append(n).append('_').append(kind); + sql.append(" FROM profiles c LEFT JOIN evidence_boundaries b ON c.evidence<=b.start_utc AND (b.previous_start IS NULL OR c.evidence>b.previous_start)), actual_sku_days AS (") + .append("SELECT s.customer_code,s.sku_code,s.business_date,CASE WHEN #{p.salesKnown}=1 AND c.unit_valid=1 AND s.unit_valid=1 THEN s.quantity END observed,") + .append("CASE WHEN d.coverage=1 AND c.unit_valid=1 AND s.unit_valid=1 THEN 1 ELSE 0 END complete,") + .append("CASE WHEN d.coverage=1 AND c.unit_valid=1 AND c.evidence<=d.start_utc THEN 1 ELSE 0 END zero_complete,") + .append("CASE WHEN #{p.salesKnown}=1 AND c.unit_valid=1 AND d.coverage>0 AND c.evidence<=d.start_utc THEN 1 ELSE 0 END zero_known,") + .append("CASE WHEN #{p.salesKnown}=1 AND c.unit_valid=1 AND d.coverage=2 AND c.evidence<=d.start_utc THEN 1 ELSE 0 END zero_partial ") + .append("FROM sales_days s JOIN profiles c ON c.customer_code=s.customer_code AND c.sku_code=s.sku_code JOIN calendar d ON d.business_date=s.business_date), sku_actual_counts AS (SELECT customer_code,sku_code"); + for(int n:List.of(7,15,30)) { + String range="business_date>=CAST(#{p.from"+n+"} AS DATE)"; + sql.append(",SUM(CASE WHEN ").append(range).append(" THEN COALESCE(observed,0) ELSE 0 END) w").append(n).append("_sum") + .append(",SUM(CASE WHEN ").append(range).append(" THEN complete-zero_complete ELSE 0 END) w").append(n).append("_complete") + .append(",SUM(CASE WHEN ").append(range).append(" THEN (CASE WHEN observed IS NOT NULL THEN 1 ELSE 0 END)-zero_known ELSE 0 END) w").append(n).append("_known") + .append(",SUM(CASE WHEN ").append(range).append(" THEN (CASE WHEN observed IS NOT NULL AND complete=0 THEN 1 ELSE 0 END)-zero_partial ELSE 0 END) w").append(n).append("_partial"); + } + // Read only actual qualified samples at the frozen date/run. Missing candidates + // remain in sku_zero_counts; absence never creates a zero-valued sample. + sql.append(" FROM actual_sku_days GROUP BY customer_code,sku_code), sku_sample_days AS (SELECT st.customer_code,st.sku_code,d.business_date,") + .append("st.physical sample_physical FROM calendar d JOIN stocks st ON st.snapshot_run_id=d.sample_run ") + .append("JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=st.customer_code AND ip.sku_code=st.sku_code ") + .append("WHERE ip.quantity_unit='PCS' AND st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,") + .append("COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") + .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,") + .append(sampleMask()).append(" sample_mask FROM sku_sample_days GROUP BY customer_code,sku_code), sku_window_counts AS (SELECT z.customer_code,z.sku_code,COALESCE(s.sample_count,0) sample_count,s.average_physical,COALESCE(s.sample_mask,0) sample_mask"); + for(int n:List.of(7,15,30)) sql.append(",CASE WHEN z.w").append(n).append("_known+COALESCE(a.w").append(n).append("_known,0)>0 THEN COALESCE(a.w").append(n).append("_sum,0) END w").append(n).append("_observed") + .append(",z.w").append(n).append("_complete+COALESCE(a.w").append(n).append("_complete,0) w").append(n).append("_complete") + .append(",z.w").append(n).append("_partial+COALESCE(a.w").append(n).append("_partial,0) w").append(n).append("_partial"); + return sql.append(" FROM sku_zero_counts z LEFT JOIN sku_actual_counts a ON a.customer_code=z.customer_code AND a.sku_code=z.sku_code ") + .append("LEFT JOIN sku_sample_counts s ON s.customer_code=z.customer_code AND s.sku_code=z.sku_code), sku_windows AS (SELECT w.*") + .append(formalWindows()).append(" FROM sku_window_counts w)").toString(); + } + private static String sampleMask() { + // Exactly 30 bits, all exactly representable even by POWER's numeric result. + return "SUM(CASE WHEN sample_physical IS NOT NULL THEN CAST(POWER(2,TIMESTAMPDIFF(DAY,CAST(#{p.from} AS DATE),business_date)) AS SIGNED) ELSE 0 END)"; + } + private static String pageMissingDates() { + // Only the selected page expands dates. Numeric qualification and filtering + // already used every candidate and every frozen sample before pagination. + return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d " + +"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND MOD(FLOOR(r.sample_mask/POWER(2,TIMESTAMPDIFF(DAY,CAST(#{p.from} AS DATE),d.business_date))),2)=0) missing_dates FROM page_result r)"; + } + private static String windowAggregates() { StringBuilder sql=new StringBuilder(); for(int n:List.of(7,15,30)) { String range="business_date>=CAST(#{p.from"+n+"} AS DATE)"; - sql.append(",CASE WHEN SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END)=").append(n) - .append(" THEN SUM(CASE WHEN ").append(range).append(" THEN COALESCE(observed,0) END) END w").append(n).append("_outbound") - .append(",SUM(CASE WHEN ").append(range).append(" THEN observed END) w").append(n).append("_observed") + sql.append(",SUM(CASE WHEN ").append(range).append(" THEN observed END) w").append(n).append("_observed") .append(",SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END) w").append(n).append("_complete") .append(",SUM(CASE WHEN ").append(range).append(" AND complete=0 AND observed IS NOT NULL THEN 1 ELSE 0 END) w").append(n).append("_partial"); } return sql.toString(); } + private static String formalWindows() { + // Derive formal values once per SKU/owner, reusing the same complete count and + // observed sum. A complete all-null observation sum retains the prior zero; + // partial/unknown windows remain null and never shorten the denominator. + var sql=new StringBuilder(); + for(int n:List.of(7,15,30)) sql.append(",CASE WHEN w.w").append(n).append("_complete=").append(n) + .append(" THEN COALESCE(w.w").append(n).append("_observed,0) END w").append(n).append("_outbound"); + return sql.toString(); + } private static String ownerCte(Input p) { - return ", owner_days AS (SELECT customer_code,business_date,MIN(owner_complete) complete," - +"SUM(owner_observed) observed," - +"CASE WHEN COUNT(sample_physical)=COUNT(*) THEN SUM(sample_physical) END sample_physical FROM day_result GROUP BY customer_code,business_date), owner_windows AS (SELECT customer_code" - +windowAggregates(true)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," + // Sales units apply to actual daily records, not profile units/evidence. + // The full candidate count distinguishes covered absent rows (known zero) + // from a day where every candidate has only an invalid actual record. + return ", owner_candidates AS (SELECT customer_code,COUNT(*) candidate_count FROM candidates GROUP BY customer_code), owner_actual_days AS (" + +"SELECT customer_code,business_date,COUNT(*) actual_count,SUM(CASE WHEN unit_valid=1 THEN 1 ELSE 0 END) valid_count," + +"SUM(CASE WHEN unit_valid=1 THEN quantity END) observed FROM sales_days GROUP BY customer_code,business_date), owner_sample_days AS (" + +"SELECT s.customer_code,s.business_date,SUM(s.sample_physical) sample_physical " + +"FROM sku_sample_days s JOIN owner_candidates c ON c.customer_code=s.customer_code GROUP BY s.customer_code,s.business_date HAVING COUNT(*)=MAX(c.candidate_count)), owner_days AS (" + +"SELECT c.customer_code,d.business_date,CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND COALESCE(a.actual_count-a.valid_count,0)=0 THEN 1 ELSE 0 END complete," + +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN a.valid_count>0 THEN a.observed WHEN d.coverage>0 AND c.candidate_count>COALESCE(a.actual_count,0) THEN 0 END END observed," + +"s.sample_physical FROM owner_candidates c CROSS JOIN calendar d LEFT JOIN owner_actual_days a ON a.customer_code=c.customer_code AND a.business_date=d.business_date " + +"LEFT JOIN owner_sample_days s ON s.customer_code=c.customer_code AND s.business_date=d.business_date), owner_window_counts AS (SELECT customer_code" + +windowAggregates()+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical," - +"GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates FROM owner_days GROUP BY customer_code), owner_totals AS (" - +totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.missing_dates," + +sampleMask()+" sample_mask FROM owner_days GROUP BY customer_code), owner_windows AS (SELECT w.*"+formalWindows()+" FROM owner_window_counts w), owner_totals AS (" + +totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.sample_mask," +"CASE WHEN w.average_physical>0 THEN w.w"+p.query.days()+"_outbound/w.average_physical END turnover," +String.join(",",List.of(7,15,30).stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList()) +" FROM owner_totals t JOIN owner_windows w ON w.customer_code=t.customer_code) "; @@ -140,7 +208,7 @@ public final class InventoryTurnoverSql { } public static String page(Map args) { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); - return cte(p)+(owner?ownerCte(p):"")+"SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset}"; + return cte(p)+(owner?ownerCte(p):"")+", page_result AS (SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"+pageMissingDates()+" SELECT * FROM page_missing"+order(p,owner); } public static String count(Map args) { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); @@ -155,10 +223,10 @@ public final class InventoryTurnoverSql { +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code"; } public static String chartWindows(Map args) { - Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT d.business_date," + Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays()+"), warehouse_days AS (SELECT d.business_date," +"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete," +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed " - +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days"; + +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates()+" FROM warehouse_days) SELECT w.*"+formalWindows()+" FROM warehouse_window_counts w"; } private static String jsonFields(List fields) { return "JSON_OBJECT("+String.join(",",fields.stream().map(f->"'"+f+"',"+f).toList())+")"; @@ -174,17 +242,21 @@ public final class InventoryTurnoverSql { for(String field:List.of("physical","available","frozen","defective"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix); for(String risk:List.of("no_outbound","slow"))for(String suffix:List.of("count","physical","unknown"))fields.add(risk+"_"+suffix); } else fields.addAll(List.of("sku_code","product_name","coverage_days","no_outbound","slow")); - String sql=cte(p)+(owner||overview?ownerCte(p):"") + // The physical-stock chart needs owner totals, not owner sales/sample windows. + String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals("customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"") +", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"; - if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)"; - sql+=" SELECT 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_result" + sql+=pageMissingDates(); + if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)"; + // Statement-local only: share intermediate relations across report branches; + // do not change connection/global settings or any result/filter semantics. + sql+=" SELECT /*+ SET_VAR(optimizer_switch='derived_merge=off') SET_VAR(optimizer_search_depth=4) */ 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_missing" +" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner); if(overview) { List summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown")); for(String field:List.of("physical","available")){summary.add(field);summary.add(field+"_known");summary.add(field+"_unknown");} sql+=" UNION ALL SELECT 'OVERVIEW' section,0 ordinal,"+jsonFields(summary)+" payload FROM summary_result" +" UNION ALL SELECT 'OWNER' section,ordinal,JSON_OBJECT('customer_code',customer_code,'physical',physical) payload FROM top_owners" - +" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_result WHERE physical IS NULL"; + +" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_totals WHERE physical IS NULL"; } return sql+" ORDER BY section,ordinal"; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java index 594492337..f4f64651e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -35,7 +35,12 @@ class InventoryTurnoverContextTest { assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate()); assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive()); assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response()); - verify(mapper,times(2)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); + var page2=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext()); + var second=at(NOW.plusSeconds(90)).resolve(page2); + assertEquals(first,second.response()); + assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(), + InventoryTurnoverSql.input(page2,second).days()); + verify(mapper,times(4)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); // A subsequently published sample cannot appear in the old asOf query. If reconstruction // ever differs, reject the context instead of mixing old charts and new rows. when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index 13018c274..af7bed0fe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -103,6 +103,16 @@ class InventoryTurnoverSqlTest { assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed")); decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count")); } + @Test void warehouseChartUsesSalesUnitsAndCoverageIndependentlyOfStockProfiles() throws Exception { + seed("A","SKU",30); + execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN'"); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,0,false); + var row=query("chartWindows",p).get(0);decimal("300",row.get("w30_outbound")); + execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN' WHERE business_date=DATE '"+END+"'"); + row=query("chartWindows",p).get(0);assertNull(row.get("w30_outbound")); + decimal("290",row.get("w30_observed"));decimal("29",row.get("w30_complete"));decimal("6",row.get("w7_complete")); + } @Test void fullScopeFilterPrecedesPaginationAndKeepsSoldOutHistoricalCandidates() throws Exception { seed("A","LOW",30);seed("A","HIGH",30);seed("B","SOLD",30); execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=3000,physical_quantity=3000 WHERE sku_code='HIGH' AND snapshot_run_id=22 AND product_quality=0"); @@ -137,6 +147,231 @@ class InventoryTurnoverSqlTest { assertEquals(2,report.stream().filter(r->"OWNER".equals(r.get("section"))).count()); assertEquals(1,report.stream().filter(r->"OVERVIEW".equals(r.get("section"))).count()); } + @Test void frozenSamplesExcludeUnselectedVersionsWithoutDroppingMissingDays() throws Exception { + seed("A","SKU",30); + profile(999,"A","UNSELECTED");stock(999,"A","UNSELECTED",0,999,999); + var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20); + var rows=reportRows(input(q,30,2,true)); + assertEquals(1,rows.size());decimal("2",rows.get(0).get("sample_count")); + assertNull(rows.get(0).get("turnover")); + assertEquals(28,rows.get(0).get("missing_dates").toString().split(",").length); + rows=reportRows(input(q,30,0,true)); + assertEquals(1,rows.size());decimal("0",rows.get(0).get("sample_count")); + assertEquals(30,rows.get(0).get("missing_dates").toString().split(",").length); + decimal("300",rows.get(0).get("w30_outbound")); + } + @Test void repeatedSampleRunAndHighestDateBitKeepAllThreeWindowsExact() throws Exception { + seed("A","SKU",30); + for(boolean onlyLast:List.of(true,false)) for(int n:List.of(7,15,30)) for(String group:List.of("SKU","OWNER")) { + var dates=new ArrayList();var missing=new ArrayList(); + for(int i=0;i<30;i++) { + var date=END.minusDays(29-i);boolean present=onlyLast?i==29:i!=29; + // Reusing a version on multiple frozen dates must retain each date. + dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,present?100L:null)); + if(i>=30-n&&!present)missing.add(date.toString()); + } + var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null); + var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates)); + var row=query("page",p).get(0); + decimal(onlyLast?"536870912":"536870911",row.get("sample_mask")); + decimal(String.valueOf(onlyLast?1:n-1),row.get("sample_count")); + assertEquals(String.join(",",missing),row.get("missing_dates")); + assertNull(row.get("average_physical"));assertNull(row.get("turnover")); + assertEquals(row.get("missing_dates"),reportRows(p).get(0).get("missing_dates")); + } + } + @Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception { + seed("A","ONE",30);seed("A","TWO",30); + // Both edge bits and a middle bit, with different missing members per day. + execute("DELETE FROM ll_selfop_inventory_snapshot WHERE warehouse_code='W' AND ((sku_code='ONE' AND snapshot_run_id IN (100,114)) OR (sku_code='TWO' AND snapshot_run_id=129))"); + for(int n:List.of(7,15,30)) { + var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null); + var p=input(q,30,30,true); + var row=reportRows(p).get(0); + var expected=new ArrayList(); + for(int index:List.of(0,14,29)) if(index>=30-n) expected.add(END.minusDays(29-index)); + decimal(String.valueOf(n-expected.size()),row.get("sample_count")); + assertEquals(String.join(",",expected.stream().map(LocalDate::toString).toList()),row.get("missing_dates")); + assertNull(row.get("average_physical"));assertNull(row.get("turnover")); + assertEquals(row.get("missing_dates"),query("page",p).get(0).get("missing_dates")); + } + var pageTwo=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,1,false,null); + var second=reportRows(input(pageTwo,30,30,true)).get(0); + assertEquals("TWO",second.get("sku_code"));decimal("29",second.get("sample_count"));assertEquals(END.toString(),second.get("missing_dates")); + // A stock/profile-only candidate without samples invalidates all owner days. + profile(22,"A","NO-SAMPLES");stock(22,"A","NO-SAMPLES",0,0,0); + var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); + decimal("0",owner.get("sample_count"));assertEquals(30,owner.get("missing_dates").toString().split(",").length); + } + @Test void reportBindsExactLargeSampleVersionsAndKeepsWindowDenominators() throws Exception { + seed("A","SKU",30); + long first=9_007_199_254_740_993L; + for(int i=0;i<30;i++) { + execute("UPDATE ll_selfop_sku_profile SET profile_run_id="+(first+i)+" WHERE profile_run_id="+(100+i)); + execute("UPDATE ll_selfop_inventory_snapshot SET snapshot_run_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE snapshot_run_id="+(100+i)); + } + List days=new ArrayList<>(); + for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));} + for(int n:List.of(7,15,30)) { + var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null); + var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days)); + var row=reportRows(p).get(0); + decimal(String.valueOf(n),row.get("sample_count")); + decimal(n==7?"270":n==15?"230":"155",row.get("average_physical")); + decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound")); + assertNotNull(row.get("turnover")); + } + var missing=days.get(14);days.set(14,new InventoryTurnoverSql.Day(missing.date(),missing.startUtc(),1,null)); + var row=reportRows(new InventoryTurnoverSql.Input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),11,22,true,true,List.copyOf(days))).get(0); + decimal("29",row.get("sample_count"));assertNull(row.get("turnover")); + assertEquals(missing.date().toString(),row.get("missing_dates")); + } + @Test void reportPaginationKeepsTotalAndCanOmitOverview() throws Exception { + seed("A","SKU",30);seed("B","SKU",30); + var first=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true)); + var secondQuery=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,1,false,"frozen"); + var second=query("report",input(secondQuery,30,30,true)); + var row=second.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow(); + assertNotEquals(first.get(0).get("customer_code"),payload(row).path("customer_code").asText()); + assertEquals(2,second.size()); + var beyond=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",3,1,false,"frozen"); + var report=query("report",input(beyond,30,30,true));assertEquals(1,report.size()); + assertEquals("TOTAL",report.get(0).get("section"));assertEquals(2,payload(report.get(0)).path("total").asInt()); + } + @Test void projectedFormalWindowsKeepPartialObservationsAndUnknownDistinctFromZero() throws Exception { + seed("A","SKU",30); + for(String group:List.of("SKU","OWNER")) { + var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true); + var row=reportRows(p).get(0); + assertNull(row.get("w30_outbound"));decimal("300",row.get("w30_observed"));decimal("30",row.get("w30_partial")); + } + execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'"); + for(String group:List.of("SKU","OWNER")) { + var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true); + var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed")); + var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.sampleRun())).toList(); + var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays)).get(0); + assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial")); + var complete=reportRows(input(p.query(),30,30,true)).get(0); + decimal("0",complete.get("w30_outbound"));decimal("30",complete.get("w30_complete")); + } + } + @Test void zeroEvidenceUsesExactBeijingMidnightIncludingMilliseconds() throws Exception { + seed("A","SKU",0);execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'"); + for(int n:List.of(7,15,30)) for(int millis:List.of(-1,0,1)) { + LocalDate start=END.minusDays(n-1);var utc=start.atStartOfDay().minusHours(8).plusNanos(millis*1_000_000L); + execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '"+utc.toString().replace('T',' ')+"'"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,0,true); + var row=reportRows(p).get(0);decimal(String.valueOf(n-(millis>0?1:0)),row.get("w"+n+"_complete")); + if(millis>0)assertNull(row.get("w"+n+"_outbound"));else decimal("0",row.get("w"+n+"_outbound")); + execute("INSERT INTO ll_selfop_sku_daily_sales VALUES(11,'demo','W','A','SKU',DATE '"+start+"',0,'PCS')"); + row=reportRows(p).get(0);decimal(String.valueOf(n),row.get("w"+n+"_complete"));decimal("0",row.get("w"+n+"_outbound")); + execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'"); + } + } + @Test void sparseCorrectionRevokesInvalidActualZeroAndHandlesEvidenceAfterLastBoundary() throws Exception { + seed("A","SKU",0);execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),1,0,true); + var afterLast=END.atStartOfDay().minusHours(8).plusNanos(1_000_000); + execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '"+afterLast.toString().replace('T',' ')+"'"); + var row=reportRows(p).get(0);decimal("0",row.get("w7_complete"));assertNull(row.get("w7_observed"));decimal("0",row.get("w7_partial")); + execute("INSERT INTO ll_selfop_sku_daily_sales VALUES(11,'demo','W','A','SKU',DATE '"+END+"',1.234567,'PCS')"); + row=reportRows(p).get(0);decimal("1",row.get("w7_complete"));decimal("1.234567",row.get("w7_observed")); + execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2026-01-01 00:00:00'"); + execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN' WHERE warehouse_code='W'"); + row=reportRows(p).get(0);decimal("0",row.get("w7_complete"));assertNull(row.get("w7_observed"));decimal("0",row.get("w7_partial")); + } + @Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception { + seed("A","ONE",30);seed("A","TWO",30); + String key=" WHERE snapshot_run_id=100 AND sku_code='TWO' AND product_quality=0"; + for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'","profile")) { + if("profile".equals(mutation))execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100 AND sku_code='TWO'"); + else execute("UPDATE ll_selfop_inventory_snapshot SET "+mutation+key); + var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); + decimal("29",owner.get("sample_count"));assertNull(owner.get("turnover"));assertEquals(END.minusDays(29).toString(),owner.get("missing_dates")); + execute("DELETE FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0); + execute("UPDATE ll_selfop_sku_profile SET quantity_unit='PCS' WHERE profile_run_id=100 AND sku_code='TWO'"); + } + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); + var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); + decimal("30",owner.get("sample_count"));decimal("0",owner.get("average_physical"));assertNull(owner.get("turnover")); + } + @Test void ownerInvalidActualAndAbsentMemberKeepCoveredZeroDifferentFromUnknown() throws Exception { + seed("A","ONE",0);seed("A","TWO",0);execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'"); + execute("INSERT INTO ll_selfop_sku_daily_sales VALUES(11,'demo','W','A','ONE',DATE '"+END+"',10,'UNKNOWN')"); + var q=request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20); + var row=reportRows(input(q,30,0,true)).get(0);decimal("6",row.get("w7_complete"));decimal("0",row.get("w7_observed"));decimal("1",row.get("w7_partial")); + row=reportRows(input(q,0,0,true)).get(0);assertNull(row.get("w7_observed"));decimal("0",row.get("w7_partial")); + execute("INSERT INTO ll_selfop_sku_daily_sales VALUES(11,'demo','W','A','TWO',DATE '"+END+"',10,'UNKNOWN')"); + row=reportRows(input(q,1,0,true)).get(0);assertNull(row.get("w7_observed"));decimal("0",row.get("w7_complete"));decimal("0",row.get("w7_partial")); + } + @Test void decimalWindowsRemainExactBeyondJavascriptIntegerRangeWithoutDuplicatingCandidates() throws Exception { + for(String sku:List.of("ONE","TWO","THREE","FOUR"))seed("A",sku,30); + String amount="99999999999999.123456";var quantity=new BigDecimal(amount); + execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity="+amount+" WHERE warehouse_code='W'"); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END"); + var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); + var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow()); + decimal(quantity.multiply(BigDecimal.valueOf(120)).toPlainString(),row.path("w30_outbound").decimalValue()); + decimal(quantity.multiply(BigDecimal.valueOf(4)).toPlainString(),row.path("average_physical").decimalValue()); + decimal("30",row.path("turnover").decimalValue());assertEquals(30,row.path("sample_count").asInt()); + var skuReport=query("report",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); + assertEquals(4,skuReport.stream().filter(r->"ROW".equals(r.get("section"))).count()); + for(var raw:skuReport)if("ROW".equals(raw.get("section")))decimal(quantity.multiply(BigDecimal.valueOf(30)).toPlainString(),payload(raw).path("w30_outbound").decimalValue()); + } + @Test void threeSourceCandidateUnionKeepsFullOwnerDenominator() throws Exception { + profile(100,"A","HISTORY");profile(11,"A","HISTORY");profile(22,"A","HISTORY"); + stock(22,"A","STOCK",0,0,0); + execute("INSERT INTO ll_selfop_sku_daily_sales VALUES(11,'demo','W','A','SALES',DATE '"+END+"',1.123456,'PCS')"); + var skuRows=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); + assertEquals(Set.of("HISTORY","STOCK","SALES"),new HashSet<>(skuRows.stream().map(r->r.get("sku_code")).toList())); + assertEquals(3,skuRows.size()); + var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); + var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow()); + decimal("1.123456",row.path("w30_outbound").decimalValue()); + assertEquals(0,row.path("sample_count").asInt());assertTrue(row.path("average_physical").isNull()); + assertEquals(30,row.path("missing_dates").asText().split(",").length); + } + @Test void ownerStockChartSurvivesMissingWindowsAndEmptySkuFilter() throws Exception { + seed("A","SKU",0);seed("B","SKU",0);seed("C","SOLD",0); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='FIELD_MISSING' WHERE customer_code='B' AND snapshot_run_id=22"); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0,available_quantity=0 WHERE customer_code='C' AND snapshot_run_id=22"); + var baseline=query("report",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),7,0,true)); + var baselineSummary=payload(baseline.stream().filter(r->"OVERVIEW".equals(r.get("section"))).findFirst().orElseThrow()); + for(String group:List.of("SKU","OWNER")) { + var full=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),7,0,true); + var standalone=query("page",full);var reportRows=reportRows(full); + assertEquals(standalone.size(),reportRows.size()); + for(int i=0;i"OWNER".equals(r.get("section"))).toList(); + assertEquals(2,chart.size()); + var known=payload(chart.get(0));assertEquals("A",known.path("customer_code").asText());decimal("610",known.path("physical").decimalValue()); + var unknown=report.stream().filter(r->"OWNER_META".equals(r.get("section"))).findFirst().orElseThrow(); + assertEquals(1,payload(unknown).path("unknown_count").asInt()); + var summary=report.stream().filter(r->"OVERVIEW".equals(r.get("section"))).findFirst().orElseThrow(); + assertEquals(3,payload(summary).path("sku_count").asInt());assertEquals(baselineSummary,payload(summary)); + var total=report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow(); + assertEquals("SKU".equals(group)?0:3,payload(total).path("total").asInt()); + assertEquals("SKU".equals(group)?0:3,report.stream().filter(r->"ROW".equals(r.get("section"))).count()); + } + } + private static com.fasterxml.jackson.databind.JsonNode payload(Map row) throws Exception { + var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);Object raw=row.get("payload"); + return raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString()); + } + private List> reportRows(InventoryTurnoverSql.Input p) throws Exception { + var result=new ArrayList>();var json=new com.fasterxml.jackson.databind.ObjectMapper(); + for(var row:query("report",p)) if("ROW".equals(row.get("section"))) + result.add(json.convertValue(payload(row),new com.fasterxml.jackson.core.type.TypeReference>(){})); + return result; + } private void seed(String owner,String sku,int samples) throws Exception { profile(11,owner,sku);profile(22,owner,sku);stock(22,owner,sku,0,600,600);stock(22,owner,sku,1,10,0); for(int i=0;i<30;i++) { @@ -159,6 +394,10 @@ class InventoryTurnoverSqlTest { Object value=meta.getValue(parameter.getProperty());String literal=value==null?"NULL":value instanceof Boolean b?b?"1":"0":value instanceof Number?value.toString():"'"+value.toString().replace("'","''")+"'"; sql=sql.replaceFirst("\\?",java.util.regex.Matcher.quoteReplacement(literal)); } + // MySQL SIGNED is a signed 64-bit integer; H2's alias is only 32-bit. + sql=sql.replace(" AS SIGNED)"," AS BIGINT)"); + // MySQL's unbounded CHAR cast preserves the date; H2 defaults to CHAR(1). + sql=sql.replace("CAST(business_date AS CHAR)","CAST(business_date AS VARCHAR(10))"); // H2 inlines repeatedly referenced CTEs exponentially. Materialize each unchanged // production relation in this local fixture; MySQL execution plans remain unverified. List relations=new ArrayList<>();int cursor=5; From 913b9e92629c2f4951e5fcd712f9e6953c02394b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 04:59:05 +0800 Subject: [PATCH 63/80] fix(selfop): persist bounded next-root quantity diagnostics --- .../LingxingQuantityCaptureProperties.java | 17 +- .../lingxing/LingxingQuantityCapture.java | 259 ++++----- .../lingxing/LingxingQuantityEvidence.java | 41 +- .../lingxing/LingxingQuantityHttpSink.java | 83 ++- .../provider/lingxing/LingxingWmsClient.java | 4 +- .../lingxing/LingxingQuantityCaptureTest.java | 180 ++---- .../LingxingQuantityHttpSinkTest.java | 85 +-- .../lingxing/LingxingWmsClientTest.java | 7 +- .../QuantityDiagnosticContract.java | 26 +- .../QuantityDiagnosticController.java | 41 +- .../diagnostic/QuantityDiagnosticPolicy.java | 106 +--- .../QuantityDiagnosticProperties.java | 7 +- .../diagnostic/QuantityDiagnosticService.java | 284 ++++++--- .../db/selfop-quantity-diagnostics.sql | 27 +- ...lfop-quantity-diagnostics-internal-v1.yaml | 549 ++++++++++++++++-- .../QuantityDiagnosticControllerTest.java | 58 +- .../QuantityDiagnosticServiceTest.java | 282 ++++----- 17 files changed, 1142 insertions(+), 914 deletions(-) diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java index 1e94414c2..95d4099e1 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingQuantityCaptureProperties.java @@ -1,19 +1,6 @@ package com.baiyue.integration.config; - -import java.time.Instant; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.DefaultValue; - -/** Fixed one-shot scope. No refresh/restart may extend the configured absolute window. */ +/** Feature gate only. Explicit, durable grants and deadlines are owned by Lastleg. */ @ConfigurationProperties("baiyue.integration.lingxing-wms.quantity-capture") -public record LingxingQuantityCaptureProperties(@DefaultValue("false") boolean enabled, - String captureId, String sourceAccount, String rootTaskId, String sessionId, - String warehouseCode, String customerCode, Instant startsAt, Instant expiresAt) { - public boolean valid() { - return text(captureId) && text(sourceAccount) && text(rootTaskId) && text(sessionId) - && text(warehouseCode) && text(customerCode) && startsAt != null && expiresAt != null - && expiresAt.isAfter(startsAt) && !expiresAt.isAfter(startsAt.plusSeconds(1800)); - } - private static boolean text(String value) { return value != null && !value.isBlank() && value.length() <= 128; } - @Override public String toString() { return "LingxingQuantityCaptureProperties[enabled=" + enabled + "]"; } -} +public record LingxingQuantityCaptureProperties(@DefaultValue("false") boolean enabled,String sourceAccount) {} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java index 8500b810c..976cc06df 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCapture.java @@ -1,171 +1,122 @@ package com.baiyue.integration.provider.lingxing; +import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; import com.baiyue.integration.config.LingxingQuantityCaptureProperties; import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.EgressContext; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.Operation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PreDestroy; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.math.BigDecimal; -import java.time.Clock; -import java.time.Instant; +import java.time.*; import java.util.*; import java.util.concurrent.*; -import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; -/** Passive bounded observer. It has no supplier client and never changes collection decisions. */ +/** Passive whitelist snapshots only. No JSON serialization, HTTP or waiting locks on egress threads. */ @Component public final class LingxingQuantityCapture { - private static final Logger LOG = LoggerFactory.getLogger(LingxingQuantityCapture.class); - private static final int MAX_ORDERS = 100, MAX_ROWS = 2000, MAX_BYTES = 2 * 1024 * 1024; - private final LingxingQuantityCaptureProperties config; - private final ObjectMapper mapper; - private final Sink sink; - private final Clock clock; - private final ThreadPoolExecutor worker; - private final Map pending = new HashMap<>(); - private final Set completed = new HashSet<>(); - private volatile String stopReason; - private int records, rows, bytes; - - @Autowired - public LingxingQuantityCapture(LingxingQuantityCaptureProperties config, ObjectMapper mapper, - ObjectProvider sink) { - this(config, mapper, sink.getIfAvailable(), Clock.systemUTC()); - } - LingxingQuantityCapture(LingxingQuantityCaptureProperties config, ObjectMapper mapper, Sink sink, Clock clock) { - this.config = config; this.mapper = mapper; this.sink = sink; this.clock = clock; - worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(MAX_ORDERS), task -> { - Thread thread = new Thread(task, "lingxing-quantity-capture"); thread.setDaemon(true); return thread; - }, new ThreadPoolExecutor.AbortPolicy()); - if (!config.enabled()) stopReason = "DISABLED"; - else if (!config.valid() || sink == null) stopReason = "CONFIGURATION_UNAVAILABLE"; - } - - /** Called only after the normal projection has successfully validated a supplier response. */ - public synchronized void observe(JsonNode data, boolean detail, EgressContext context, Instant observedAt) { - try { - if (!active() || context == null || !context.wellFormed() - || !Objects.equals(config.sourceAccount(), context.sourceAccount()) - || !Objects.equals(config.rootTaskId(), context.rootTaskId()) - || !Objects.equals(config.sessionId(), context.sessionId())) return; - if (detail) enqueue(data, true, context, observedAt); - else for (JsonNode row : data.path("records")) { - if (!active()) break; - enqueue(row, false, context, observedAt); - } - } catch (RuntimeException error) { stop("OBSERVATION_FAILED"); } - } - private void enqueue(JsonNode data, boolean detail, EgressContext context, Instant time) { - String warehouse = text(data, "whCode", 64), customer = text(data, "customerCode", 64); - if (!("*".equals(config.warehouseCode()) || config.warehouseCode().equals(warehouse)) - || !("*".equals(config.customerCode()) || config.customerCode().equals(customer))) return; - Key key = new Key(warehouse, customer, text(data, "deliveryNo", 128)); - if (completed.contains(key)) return; - // Freeze the first 100 list candidates. Extra page rows and unselected details cost no retained memory. - if (detail && !pending.containsKey(key)) return; - if (!detail && (pending.containsKey(key) || pending.size() + completed.size() >= MAX_ORDERS)) return; - JsonNode items = data.path(detail ? "skuVOList" : "productList"); - if (!items.isArray() || items.isEmpty() || items.size() > 200) { stop("SKU_RECORD_LIMIT_OR_MISSING"); return; } - Map quantities = new TreeMap<>(); - for (JsonNode item : items) { - String sku = text(item, "productSku", 128); - Quantities next = new Quantities(detail ? null : quantity(item.get("qty")), - detail ? quantity(item.get("deliveryQty")) : null, - detail ? quantity(item.get("actualDeliveryQty")) : null); - quantities.merge(sku, next, Quantities::add); - } - Observation observation = new Observation(data.path("status").isInt() ? data.path("status").asText() : null, - optionalText(data, "outboundTime", 64), time, context.attemptId(), true); - Sample sample = new Sample(key, detail, observation, quantities, items.size()); - process(sample); - } - private void process(Sample sample) { - try { - if (!active()) return; - if (++records > 2000 || (rows += sample.rowCount()) > MAX_ROWS - || (bytes += mapper.writeValueAsBytes(sample).length) > MAX_BYTES) { stop("CAPTURE_LIMIT"); return; } - if (completed.contains(sample.key())) return; - // Directory and detail stages can be far apart: candidates live until the fixed session expiry. - Pending pair = pending.computeIfAbsent(sample.key(), ignored -> new Pending()); - // Keep the first observation on each side; retries cannot silently replace its generation. - if (sample.detail()) { if (pair.detail == null) pair.detail = sample; } - else if (pair.list == null) pair.list = sample; - if (pair.list == null || pair.detail == null) return; - Set skus = new TreeSet<>(pair.list.quantities().keySet()); skus.addAll(pair.detail.quantities().keySet()); - if (skus.size() > 200) { stop("ORDER_SKU_LIMIT"); return; } - List lines = new ArrayList<>(); boolean difference = false; - for (String sku : skus) { - Quantities list = pair.list.quantities().get(sku), detail = pair.detail.quantities().get(sku); - BigDecimal forecast = detail == null ? null : detail.delivery, actual = detail == null ? null : detail.actual; - difference |= forecast != null && actual != null && forecast.compareTo(actual) != 0; - lines.add(new Line(sku, list != null, detail != null, list == null ? null : list.list, forecast, actual)); - } - Key key = sample.key(); - LingxingQuantityEvidence evidence = new LingxingQuantityEvidence(config.captureId(), config.sourceAccount(), - config.rootTaskId(), config.sessionId(), key.warehouse(), key.customer(), key.order(), - pair.list.observation(), pair.detail.observation(), List.copyOf(lines)); - worker.execute(() -> { - try { - if (!clock.instant().isBefore(config.expiresAt())) { abortTransmission("EXPIRED"); return; } - sink.accept(evidence); - } catch (ReceiverStopped normalStop) { abortTransmission(normalStop.reason()); } - catch (Exception failure) { abortTransmission("DIAGNOSTIC_FAILED"); } - }); - completed.add(key); pending.remove(key); - if (difference) stop("FORECAST_ACTUAL_DIFFERENCE"); - else if (completed.size() >= MAX_ORDERS) stop("ORDER_LIMIT"); - } catch (Exception error) { stop("DIAGNOSTIC_FAILED"); } - } - private boolean active() { - if (stopReason != null) return false; - if (!clock.instant().isBefore(config.expiresAt())) { stop("EXPIRED"); return false; } - return !clock.instant().isBefore(config.startsAt()); - } - private synchronized void stop(String reason) { - if (stopReason != null) return; - stopReason = reason; - if (!"ORDER_LIMIT".equals(reason) && !"FORECAST_ACTUAL_DIFFERENCE".equals(reason)) worker.getQueue().clear(); - pending.clear(); - LOG.info("Lingxing quantity capture stopped: {}", reason); - } - private synchronized void abortTransmission(String reason) { - worker.getQueue().clear(); pending.clear(); - if (!reason.equals(stopReason)) { - stopReason = reason; LOG.info("Lingxing quantity capture stopped: {}", reason); - } - } - public String stopReason() { return stopReason; } - @PreDestroy public void close() { stop("SHUTDOWN"); worker.shutdownNow(); } - private static String text(JsonNode data, String field, int max) { - String value = optionalText(data, field, max); - if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing diagnostic identity"); - return value; - } - private static String optionalText(JsonNode data, String field, int max) { - JsonNode value = data.get(field); - if (value == null || value.isNull()) return null; - if (!value.isTextual() || value.textValue().length() > max) throw new IllegalArgumentException("Invalid diagnostic text"); - return value.textValue(); - } - private static BigDecimal quantity(JsonNode value) { - if (value == null || value.isNull() || value.isTextual() && value.textValue().isBlank()) return null; - if (!value.isNumber()) throw new IllegalArgumentException("Invalid diagnostic quantity"); - BigDecimal result = value.decimalValue(); - if (result.precision() > 30 || Math.abs(result.scale()) > 10) throw new IllegalArgumentException("Quantity limit"); - return result; - } - private record Key(String warehouse, String customer, String order) { } - private record Quantities(BigDecimal list, BigDecimal delivery, BigDecimal actual) { - Quantities add(Quantities other) { return new Quantities(sum(list, other.list), sum(delivery, other.delivery), sum(actual, other.actual)); } - private static BigDecimal sum(BigDecimal a, BigDecimal b) { return a == null || b == null ? null : a.add(b); } - } - private record Sample(Key key, boolean detail, Observation observation, Map quantities, int rowCount) { } - private static final class Pending { - private Sample list, detail; - } + private static final int MAX_BYTES=2097152,MAX_ROWS=2000,MAX_QUEUE=100; + private final LingxingQuantityCaptureProperties config;private final Sink sink;private final Clock clock; + private final String producer=UUID.randomUUID().toString(); + private final ScheduledExecutorService worker; + private final ReentrantLock intake=new ReentrantLock(); + private final ArrayDeque queue=new ArrayDeque<>(); + private final Set selected=new HashSet<>(),details=new HashSet<>(); + private final AtomicReference failure=new AtomicReference<>(); + private String observedRoot; + private volatile View grant;private volatile boolean closed;private int bytes,rows;private boolean attached; + @Autowired public LingxingQuantityCapture(LingxingQuantityCaptureProperties config,ObjectMapper mapper,ObjectProvider sink){this(config,mapper,sink.getIfAvailable(),Clock.systemUTC());} + LingxingQuantityCapture(LingxingQuantityCaptureProperties config,ObjectMapper mapper,Sink sink,Clock clock){this(config,sink,clock,true);} + LingxingQuantityCapture(LingxingQuantityCaptureProperties config,Sink sink,Clock clock,boolean start){ + this.config=config;this.sink=sink;this.clock=clock; + worker=Executors.newSingleThreadScheduledExecutor(r->{Thread t=new Thread(r,"lingxing-quantity-capture");t.setDaemon(true);return t;}); + if(start&&config.enabled()&&sink!=null)worker.scheduleWithFixedDelay(this::pump,0,2,TimeUnit.SECONDS); + } + public void observe(JsonNode data,Operation operation,EgressContext context,Instant observedAt){ + View current=grant; + if(closed||!config.enabled()||failure.get()!=null||current==null||"TERMINAL".equals(current.state())||"DRAINING".equals(current.state())||!clock.instant().isBefore(current.expiresAt()))return; + if(context==null||!context.wellFormed()||!"BUSINESS".equals(String.valueOf(context.executionPurpose()))||!Objects.equals(config.sourceAccount(),context.sourceAccount()))return; + if(operation!=Operation.OUTBOUND_QUERY&&operation!=Operation.OUTBOUND_DETAIL)return; + if(current.rootTaskId()!=null&&!current.rootTaskId().equals(context.rootTaskId())||Objects.equals(current.excludedRootTaskId(),context.rootTaskId()))return; + if(!intake.tryLock()){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + try{ + if(current!=grant){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + if(operation==Operation.OUTBOUND_DETAIL)snapshot(data,true,context,observedAt,current); + else {JsonNode records=data.path("records");if(!records.isArray()||records.size()>100){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + for(JsonNode row:records){if(failure.get()!=null)break;snapshot(row,false,context,observedAt,current);}} + }catch(RuntimeException bad){failure.compareAndSet(null,"OBSERVER_FAILED");}finally{intake.unlock();} + } + private void snapshot(JsonNode data,boolean detail,EgressContext c,Instant time,View g){ + String warehouse=text(data,"whCode",64),owner=text(data,"customerCode",128),order=text(data,"deliveryNo",128); + if(!g.warehouseCodes().contains(warehouse)||!("*".equals(g.ownerCode())||g.ownerCode().equals(owner)))return; + if(observedRoot!=null&&!observedRoot.equals(c.rootTaskId()))return; + String key=key(c.rootTaskId(),warehouse,owner,order); + if(detail&&(!selected.contains(key)||details.contains(key)))return; + if(!detail&&(selected.contains(key)||selected.size()>=100))return; + JsonNode items=data.path(detail?"skuVOList":"productList");boolean complete=items.isArray()&&!items.isEmpty(); + if(items.size()>200){failure.compareAndSet(null,"OBSERVER_FAILED");return;} + // Conservative UTF-8 bound: identifiers <=128 chars, quantity <=38 digits, bounded metadata. + int retained=4096+items.size()*1024; + if(queue.size()>=MAX_QUEUE||rows+items.size()>MAX_ROWS||bytes+retained>MAX_BYTES){failure.compareAndSet(null,"QUEUE_FULL");return;} + var quantities=new TreeMap(); + if(items.isArray())for(JsonNode item:items){String sku=text(item,"productSku",128); + Sku value=new Sku(sku,detail?null:quantity(item.get("qty")),detail?quantity(item.get("deliveryQty")):null,detail?quantity(item.get("actualDeliveryQty")):null); + quantities.merge(sku,value,(a,b)->new Sku(sku,sum(a.listQty(),b.listQty()),sum(a.forecastQty(),b.forecastQty()),sum(a.actualQty(),b.actualQty()))); + } + String status=data.path("status").isIntegralNumber()?data.path("status").asText():null; + if(status!=null&&status.length()>32)throw new IllegalArgumentException(); + Event event=new Event(g.diagnosticSessionId(),c.sourceAccount(),producer,c.rootTaskId(),c.sessionId(),detail?"OUTBOUND_DETAIL":"OUTBOUND_QUERY",warehouse,owner,order,detail?"DETAIL":"LIST",new Observation(status,optional(data,"outboundTime",64),time,c.attemptId(),complete),List.copyOf(quantities.values())); + queue.addLast(new Queued(event,retained,items.size()));bytes+=retained;rows+=items.size(); + if(detail)details.add(key);else {selected.add(key);observedRoot=c.rootTaskId();} + } + /** Single worker owns HTTP; status(drained) occurs only after previously queued observations finish. */ + void pump(){ + if(closed||!config.enabled()||sink==null)return; + try{ + if(grant==null||"TERMINAL".equals(grant.state())){ + View next=sink.status(new Status(config.sourceAccount(),producer,!attached,false));attached=true; + if(next==null)return; + if(grant==null||!grant.diagnosticSessionId().equals(next.diagnosticSessionId()))reset(next);else grant=next; + } + if(grant==null||"TERMINAL".equals(grant.state()))return; + if(failure.get()!=null){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;} + while(true){ + if(failure.get()!=null)break; + Queued next=poll();if(next==null)break; + try{ + if(!clock.instant().isBefore(grant.expiresAt())){failure.compareAndSet(null,"INCOMPLETE");break;} + Receipt receipt=sink.observe(next.event());if(receipt==null||receipt.session()==null)throw new IllegalStateException(); + grant=receipt.session(); + if("TERMINAL".equals(grant.state())){clearQueue();break;} + }finally{release(next);} + } + if(failure.get()!=null&&!"TERMINAL".equals(grant.state())){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;} + if(!"TERMINAL".equals(grant.state())){ + View next=sink.status(new Status(config.sourceAccount(),producer,false,false));if(next==null)throw new IllegalStateException();grant=next; + if("TERMINAL".equals(next.state()))clearQueue(); + // After DRAINING is visible to intake, a later pump drains its tail before acknowledging. + if("DRAINING".equals(next.state())&&empty())grant=sink.status(new Status(config.sourceAccount(),producer,false,true)); + } + }catch(Exception failed){failure.compareAndSet(null,"INCOMPLETE");} + } + private Queued poll(){intake.lock();try{return queue.pollFirst();}finally{intake.unlock();}} + private void release(Queued q){intake.lock();try{bytes-=q.bytes();rows-=q.rows();}finally{intake.unlock();}} + private boolean empty(){intake.lock();try{return queue.isEmpty();}finally{intake.unlock();}} + private void clearQueue(){intake.lock();try{for(Queued q:queue){bytes-=q.bytes();rows-=q.rows();}queue.clear();}finally{intake.unlock();}} + private void reset(View v){intake.lock();try{queue.clear();bytes=0;rows=0;selected.clear();details.clear();failure.set(null);observedRoot=v.rootTaskId(); + for(Key k:v.selected()){String key=key(v.rootTaskId(),k.warehouseCode(),k.ownerCode(),k.orderNo());selected.add(key);if(k.paired())details.add(key);}grant=v; + }finally{intake.unlock();}} + private static String key(String root,String warehouse,String owner,String order){return root+"\u001f"+warehouse+"\u001f"+owner+"\u001f"+order;} + public String stopReason(){return failure.get()!=null?failure.get():grant==null?null:grant.stopReason();} + @PreDestroy public void close(){closed=true;worker.shutdownNow();clearQueue();} + private static String text(JsonNode n,String field,int max){String s=optional(n,field,max);if(s==null||s.isBlank())throw new IllegalArgumentException();return s;} + private static String optional(JsonNode n,String field,int max){JsonNode v=n.get(field);if(v==null||v.isNull())return null;if(!v.isTextual()||v.textValue().length()>max||v.textValue().codePoints().anyMatch(Character::isISOControl))throw new IllegalArgumentException();return v.textValue();} + private static BigDecimal quantity(JsonNode n){if(n==null||!n.isNumber())return null;BigDecimal value=n.decimalValue();return value.signum()<0||value.scale()>6||value.precision()-value.scale()>32?null:value.stripTrailingZeros();} + private static BigDecimal sum(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);} + private record Queued(Event event,int bytes,int rows){} } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java index 0e871d723..4b3601e0d 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityEvidence.java @@ -1,34 +1,17 @@ package com.baiyue.integration.provider.lingxing; - import java.math.BigDecimal; import java.time.Instant; import java.util.List; - -/** Whitelist only; never includes source payload, addresses, labels or credentials. */ -public record LingxingQuantityEvidence(String diagnosticSessionId, String sourceAccount, String rootTaskId, - String collectionSessionId, String warehouseCode, String ownerCode, String orderNo, - Observation listObservation, Observation detailObservation, List skus) { - public record Observation(String status, String outboundTime, Instant observedAt, String attemptId, - boolean skuSetComplete) { } - public record Line(String sku, boolean listPresent, boolean detailPresent, BigDecimal listQty, - BigDecimal forecastQty, BigDecimal actualQty) { } - public boolean skuSetEqual() { return skus.stream().allMatch(line -> line.listPresent() && line.detailPresent()); } - public boolean forecastActualDifference() { return skus.stream().anyMatch(line -> line.forecastQty() != null - && line.actualQty() != null && line.forecastQty().compareTo(line.actualQty()) != 0); } - /** Receiver must durably enforce window, deduplication and cumulative bounds across restarts. */ - public interface Sink { void accept(LingxingQuantityEvidence evidence); } - /** Normal durable receiver decision, including an accepted final order; not a transport failure. */ - public static final class ReceiverStopped extends RuntimeException { - private final String reason; - private final boolean orderStored; - public ReceiverStopped(String reason, boolean orderStored) { - super("Diagnostic receiver stopped"); - if (reason == null || !reason.matches("[A-Z][A-Z0-9_]{0,63}")) { - throw new IllegalArgumentException("Invalid diagnostic stop reason"); - } - this.reason = reason; this.orderStored = orderStored; - } - public String reason() { return reason; } - public boolean orderStored() { return orderStored; } - } +/** Internal diagnostic whitelist; excludes raw payloads, addresses and supplier credentials. */ +public final class LingxingQuantityEvidence { + private LingxingQuantityEvidence(){} + public record Status(String sourceAccount,String producerId,boolean attach,boolean drained){} + public record Stop(String diagnosticSessionId,String sourceAccount,String producerId,String reason){} + public record Observation(String status,String outboundTime,Instant observedAt,String attemptId,Boolean skuSetComplete){} + public record Sku(String sku,BigDecimal listQty,BigDecimal forecastQty,BigDecimal actualQty){} + public record Event(String diagnosticSessionId,String sourceAccount,String producerId,String rootTaskId,String collectionSessionId,String operation,String warehouseCode,String ownerCode,String orderNo,String side,Observation observation,List skus){} + public record Key(String warehouseCode,String ownerCode,String orderNo,boolean paired){} + public record View(String diagnosticSessionId,String sourceAccount,String rootTaskId,String collectionSessionId,String state,String stopReason,Instant startsAt,Instant expiresAt,Instant bindBefore,List warehouseCodes,String ownerCode,String excludedRootTaskId,int candidates,int pairedOrders,int retainedRows,long retainedBytes,boolean deliveryUncertain,List selected){} + public record Receipt(String outcome,String comparison,View session){} + public interface Sink {View status(Status request);Receipt observe(Event event);View stop(Stop request);} } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java index 0a5571b22..897baff0e 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java @@ -1,7 +1,6 @@ package com.baiyue.integration.provider.lingxing; - +import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; import com.baiyue.integration.config.LingxingEgressProperties; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -9,49 +8,43 @@ import java.net.URI; import java.net.http.*; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Set; -/** Async worker calls this once per paired order. No retry and no supplier requests. */ +/** Invoked only by the bounded observer worker. Never retries or contacts the supplier. */ @Component -public final class LingxingQuantityHttpSink implements LingxingQuantityEvidence.Sink { - private static final String PATH = "/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders"; - private final URI baseUri; - private final ObjectMapper mapper; - private final String token; - private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)) - .followRedirects(HttpClient.Redirect.NEVER).build(); - public LingxingQuantityHttpSink(LingxingEgressProperties config, ObjectMapper mapper, - @Value("${baiyue.security.internal-service-token:}") String token) { - this.baseUri = config.budgetAuthorityBaseUri(); - this.mapper = mapper.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - this.token = token; - } - @Override public void accept(LingxingQuantityEvidence evidence) { - try { - if (baseUri == null || token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable(); - byte[] body = mapper.writeValueAsBytes(evidence); - if (body.length > 256 * 1024 || evidence.skus().size() > 200) throw unavailable(); - HttpRequest request = HttpRequest.newBuilder(baseUri.resolve(PATH)).timeout(Duration.ofSeconds(3)) - .header("X-Internal-Service-Token", token).header("Content-Type", "application/json; charset=UTF-8") - .POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() != 200 || response.body().length() > 16384) throw unavailable(); - JsonNode root = mapper.readTree(response.body()); - if (root == null || !"200".equals(root.path("code").textValue())) throw unavailable(); - String outcome = root.path("data").path("outcome").asText(); - if (!Set.of("ACCEPTED", "DUPLICATE", "STOPPED").contains(outcome)) throw unavailable(); - // Receiver may accept the final order and atomically close its durable session. - JsonNode reason = root.path("data").path("stopReason"); - if (!reason.isMissingNode() && !reason.isNull()) { - if (!reason.isTextual() || !reason.textValue().matches("[A-Z][A-Z0-9_]{0,63}")) throw unavailable(); - throw new LingxingQuantityEvidence.ReceiverStopped(reason.textValue(), !"STOPPED".equals(outcome)); - } - if ("STOPPED".equals(outcome)) throw unavailable(); - } catch (LingxingQuantityEvidence.ReceiverStopped normalStop) { - throw normalStop; - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); throw unavailable(); - } catch (Exception failed) { throw unavailable(); } - } - private static IllegalStateException unavailable() { return new IllegalStateException("DIAGNOSTIC_RECEIVER_UNAVAILABLE"); } +public final class LingxingQuantityHttpSink implements Sink { + private static final String PATH="/internal/lastleg/self-operated-analytics/quantity-diagnostics/"; + private final URI baseUri;private final ObjectMapper json;private final String token; + private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).followRedirects(HttpClient.Redirect.NEVER).build(); + public LingxingQuantityHttpSink(LingxingEgressProperties config,ObjectMapper json,@Value("${baiyue.security.internal-service-token:}") String token){this.baseUri=config.budgetAuthorityBaseUri();this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);this.token=token;} + public View status(Status request){return call("status",request,View.class);} + public Receipt observe(Event event){return call("observe",event,Receipt.class);} + public View stop(Stop request){return call("stop",request,View.class);} + private T call(String path,Object payload,Class type){ + try{ + if(baseUri==null||token==null||token.getBytes(StandardCharsets.UTF_8).length<32)throw unavailable(); + byte[] body=json.writeValueAsBytes(payload);if(body.length>262144)throw unavailable(); + HttpRequest request=HttpRequest.newBuilder(baseUri.resolve(PATH+path)).timeout(Duration.ofSeconds(3)).header("X-Internal-Service-Token",token).header("Content-Type","application/json; charset=UTF-8").POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); + // Limit while receiving, not after allocating an arbitrary response String. + HttpResponse response=client.send(request,info->new LimitedBody(262144)); + if(response.statusCode()!=200)throw unavailable(); + var root=json.readTree(response.body());if(root==null||!"200".equals(root.path("code").asText()))throw unavailable(); + if(root.path("data").isNull())return null; + return json.treeToValue(root.path("data"),type); + }catch(InterruptedException e){Thread.currentThread().interrupt();throw unavailable();} + catch(Exception e){throw unavailable();} + } + private static IllegalStateException unavailable(){return new IllegalStateException("DIAGNOSTIC_RECEIVER_UNAVAILABLE");} + static final class LimitedBody implements HttpResponse.BodySubscriber { + private final int limit;private final java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream(); + private final java.util.concurrent.CompletableFuture result=new java.util.concurrent.CompletableFuture<>(); + private java.util.concurrent.Flow.Subscription subscription; + LimitedBody(int limit){this.limit=limit;} + public java.util.concurrent.CompletionStage getBody(){return result;} + public void onSubscribe(java.util.concurrent.Flow.Subscription s){subscription=s;s.request(1);} + public void onNext(java.util.List buffers){ + for(var buffer:buffers){if(buffer.remaining()>limit-out.size()){subscription.cancel();result.completeExceptionally(unavailable());return;}byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);out.writeBytes(bytes);}subscription.request(1); + } + public void onError(Throwable error){result.completeExceptionally(error);} + public void onComplete(){result.complete(out.toByteArray());} + } } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java index 33f3d4373..e87136021 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -192,9 +192,9 @@ public final class LingxingWmsClient { } T result = projection.apply(json, handle.metadata()); if (quantityCapture != null && (operation == Operation.OUTBOUND_QUERY - || operation == Operation.OUTBOUND_LOCATE || operation == Operation.OUTBOUND_DETAIL)) { + || operation == Operation.OUTBOUND_DETAIL)) { // Diagnostics must never alter the normal projection or the egress completion result. - try { quantityCapture.observe(json, operation == Operation.OUTBOUND_DETAIL, context, clock.instant()); } + try { quantityCapture.observe(json, operation, context, clock.instant()); } catch (RuntimeException ignored) { /* Passive diagnostic failure is isolated. */ } } return result; diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java index 7624cd4d5..d6b50bd31 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityCaptureTest.java @@ -1,146 +1,50 @@ package com.baiyue.integration.provider.lingxing; - +import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*; +import static org.assertj.core.api.Assertions.*; import com.baiyue.integration.config.LingxingQuantityCaptureProperties; import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import java.time.*; +import java.util.*; import java.util.concurrent.*; -import static org.assertj.core.api.Assertions.*; - +import org.junit.jupiter.api.Test; class LingxingQuantityCaptureTest { - private static final Instant NOW = Instant.parse("2026-10-13T01:00:00Z"); - private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); - private final BlockingQueue saved = new LinkedBlockingQueue<>(); - private EgressContext context(String session) { return new EgressContext("account", CollectionMode.BUSINESS, - "root", session, "attempt", "grant"); } - private LingxingQuantityCapture capture(Instant expiry, LingxingQuantityEvidence.Sink sink) { - return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true, "capture", "account", - "root", "session", "*", "*", NOW.minusSeconds(1), expiry), mapper, sink, Clock.fixed(NOW, ZoneOffset.UTC)); - } - private void observe(LingxingQuantityCapture c, boolean detail, String items, String session, int status) throws Exception { - String order = "{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D\",\"status\":" + status - + ",\"" + (detail ? "skuVOList" : "productList") + "\":[" + items + "]}"; - c.observe(mapper.readTree(detail ? order : "{\"records\":[" + order + "]}"), detail, context(session), NOW); - } - @ParameterizedTest @ValueSource(ints = {10, 8}) - void preservesBothSemanticOutcomesAndStopsAtDifference(int listQty) throws Exception { - try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { - observe(c.value, false, "{\"productSku\":\"S\",\"qty\":" + listQty + "}", "session", 100); - observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":8}", "session", 100); - var e = saved.poll(3, TimeUnit.SECONDS); - assertThat(e).isNotNull(); assertThat(e.forecastActualDifference()).isTrue(); - assertThat(e.skus().get(0).listQty()).isEqualByComparingTo(Integer.toString(listQty)); - assertThat(e.skuSetEqual()).isTrue(); - awaitStop(c.value, "FORECAST_ACTUAL_DIFFERENCE"); - } - } - @Test void duplicateDecimalsUnknownsAndSkuMismatchRemainVisibleForCancelledOrder() throws Exception { - try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { - observe(c.value, false, "{\"productSku\":\"S\",\"qty\":0.1},{\"productSku\":\"S\",\"qty\":0.2}," - + "{\"productSku\":\"LIST_ONLY\",\"qty\":1}", "session", 99); - observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":null}," - + "{\"productSku\":\"S\",\"deliveryQty\":2,\"actualDeliveryQty\":1}", "session", 99); - var e = saved.poll(3, TimeUnit.SECONDS); - assertThat(e).isNotNull(); assertThat(e.skuSetEqual()).isFalse(); - var line = e.skus().stream().filter(v -> v.sku().equals("S")).findFirst().orElseThrow(); - assertThat(line.listQty()).isEqualByComparingTo("0.3"); - assertThat(line.forecastQty()).isEqualByComparingTo("3"); assertThat(line.actualQty()).isNull(); - assertThat(e.detailObservation().status()).isEqualTo("99"); assertThat(e.detailObservation().outboundTime()).isNull(); - assertThat(e.forecastActualDifference()).isFalse(); - } - } - @Test void crossSessionCannotPairAndExpiredCaptureDoesNotWrite() throws Exception { - try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { - observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "other", 100); - observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100); - assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull(); - } - try (var c = new CloseableCapture(capture(NOW, saved::add))) { - observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100); - assertThat(c.value.stopReason()).isEqualTo("EXPIRED"); assertThat(saved).isEmpty(); - } - } - @Test void sinkFailureStopsOnlyDiagnosticAndNeverEscapesCaller() throws Exception { - try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { throw new IllegalStateException("private"); }))) { - observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100); - observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100); - awaitStop(c.value, "DIAGNOSTIC_FAILED"); - } - } - @Test void hundredCompleteOrdersStopsFurtherCapture() throws Exception { - try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) { - for (int i = 0; i < 100; i++) { - String head = "\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D" + i + "\",\"status\":100,"; - c.value.observe(mapper.readTree("{\"records\":[{" + head + "\"productList\":[{\"productSku\":\"S\",\"qty\":1}]}]}"), false, context("session"), NOW); - c.value.observe(mapper.readTree("{" + head + "\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}]}"), true, context("session"), NOW); - assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull(); - } - awaitStop(c.value, "ORDER_LIMIT"); - observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100); - assertThat(saved).isEmpty(); - } - } - @Test void thousandRowPageSelectsHundredAndSlowSinkDoesNotBlockDetails() throws Exception { - CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); - try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { - entered.countDown(); - try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); } - saved.add(evidence); - }))) { - var page = mapper.createObjectNode(); var records = page.putArray("records"); - for (int i = 0; i < 1000; i++) { - var order = records.addObject().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100); - order.putArray("productList").addObject().put("productSku", "S").put("qty", 1); - } - c.value.observe(page, false, context("session"), NOW); - assertThat(c.value.stopReason()).isNull(); - // Unselected details arrive first: they must not consume queue or evidence capacity. - for (int i = 999; i >= 0; i--) { - var detail = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100); - detail.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1); - c.value.observe(detail, true, context("session"), NOW); - } - assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue(); - assertThat(c.value.stopReason()).isEqualTo("ORDER_LIMIT"); - release.countDown(); - for (int i = 0; i < 100; i++) assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull(); - assertThat(saved).isEmpty(); - } finally { release.countDown(); } - } - private static void awaitStop(LingxingQuantityCapture capture, String reason) throws Exception { - long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); - while (capture.stopReason() == null && System.nanoTime() < end) Thread.sleep(5); - assertThat(capture.stopReason()).isEqualTo(reason); - } - @ParameterizedTest @ValueSource(strings = {"FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED"}) - void savedFinalOrderRetainsNormalReceiverReasonAndDiscardsQueuedOrders(String reason) throws Exception { - CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); - try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { - entered.countDown(); - try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); } - saved.add(evidence); // Receiver persisted the last accepted order before returning its stop decision. - throw new LingxingQuantityEvidence.ReceiverStopped(reason, true); - }))) { - for (int i = 0; i < 3; i++) { - var order = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100); - order.putArray("productList").addObject().put("productSku", "S").put("qty", 1); - var page = mapper.createObjectNode(); page.putArray("records").add(order); - c.value.observe(page, false, context("session"), NOW); - order.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1); - c.value.observe(order, true, context("session"), NOW); - } - assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue(); - release.countDown(); awaitStop(c.value, reason); - assertThat(saved).hasSize(1); - assertThat(saved.remove().orderNo()).isEqualTo("D0"); - assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull(); - } finally { release.countDown(); } - } - private record CloseableCapture(LingxingQuantityCapture value) implements AutoCloseable { - @Override public void close() { value.close(); } - } + final Instant now=Instant.parse("2026-10-14T00:00:00Z");final ObjectMapper json=new ObjectMapper(); + final EgressContext context=new EgressContext("account",CollectionMode.BUSINESS,"2","selfop-2","attempt","grant"); + View view(){return new View("diag","account",null,null,"ARMED",null,now,now.plusSeconds(36000),now.plusSeconds(21600),List.of("W"),"*","1",0,0,0,0,false,List.of());} + com.fasterxml.jackson.databind.JsonNode list(String id)throws Exception{return json.readTree("{\"records\":[{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\""+id+"\",\"productList\":[{\"productSku\":\"S\",\"qty\":10}]}]}");} + class Fake implements Sink {View state=view();List received=new ArrayList<>();String stopped;public View status(Status s){return state;}public Receipt observe(Event e){received.add(e);return new Receipt("ACCEPTED",null,state);}public View stop(Stop s){stopped=s.reason();return state;}} + LingxingQuantityCapture capture(Sink sink){return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true,"account"),sink,Clock.fixed(now,ZoneOffset.UTC),false);} + @Test void observationDoesNotCallReceiverAndLocateNeverBinds()throws Exception{ + Fake sink=new Fake();try(var c=new TestClose(capture(sink))){c.value.pump();c.value.observe(list("a"),Operation.OUTBOUND_LOCATE,context,now);c.value.pump();assertThat(sink.received).isEmpty(); + c.value.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThat(sink.received).isEmpty();c.value.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).operation()).isEqualTo("OUTBOUND_QUERY");} + } + @Test void slowReceiverNeverBlocksNormalObservationAndFullQueueStopsOnlyDiagnosis()throws Exception{ + CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1);Fake sink=new Fake(){public Receipt observe(Event e){entered.countDown();try{release.await(3,TimeUnit.SECONDS);}catch(InterruptedException x){Thread.currentThread().interrupt();}return super.observe(e);}}; + var c=capture(sink);c.pump();c.observe(list("first"),Operation.OUTBOUND_QUERY,context,now);var worker=Executors.newSingleThreadExecutor();try{var pending=worker.submit(c::pump);assertThat(entered.await(1,TimeUnit.SECONDS)).isTrue(); + assertThatCode(()->{for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now);}).doesNotThrowAnyException(); + release.countDown();pending.get(5,TimeUnit.SECONDS); + }finally{release.countDown();worker.shutdownNow();c.close();} + } + @Test void failureIsolatedAndNoRearmOfSameGrant()throws Exception{ + Fake sink=new Fake(){public Receipt observe(Event e){throw new IllegalStateException("receiver down");}};var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThatCode(c::pump).doesNotThrowAnyException();assertThat(c.stopReason()).isEqualTo("INCOMPLETE");c.pump();assertThat(sink.stopped).isEqualTo("INCOMPLETE");}finally{c.close();} + } + @Test void selectedCandidatesRecoveredWithoutReobservingList()throws Exception{ + Fake sink=new Fake();sink.state=new View("diag","account","2","selfop-2","BOUND",null,now,now.plusSeconds(14400),now.plusSeconds(21600),List.of("W"),"*","1",1,0,1,100,true,List.of(new Key("W","C","a",false))); + var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);c.observe(json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"a\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}"),Operation.OUTBOUND_DETAIL,context,now);c.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).side()).isEqualTo("DETAIL");}finally{c.close();} + } + @Test void malformedOrOversizedSnapshotsAreIsolated()throws Exception{ + Fake sink=new Fake();var c=capture(sink);try{c.pump();var data=json.createObjectNode();var records=data.putArray("records");for(int i=0;i<101;i++)records.addObject();assertThatCode(()->c.observe(data,Operation.OUTBOUND_QUERY,context,now)).doesNotThrowAnyException();c.pump();assertThat(sink.stopped).isEqualTo("OBSERVER_FAILED");assertThat(sink.received).isEmpty();}finally{c.close();} + } + @Test void queueIsBoundedAndNoProducerWaitsForIt()throws Exception{ + Fake sink=new Fake();var c=capture(sink);try{c.pump();for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now); + var detail=json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"o0\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}"); + c.observe(detail,Operation.OUTBOUND_DETAIL,context,now);assertThat(c.stopReason()).isEqualTo("QUEUE_FULL");c.pump();assertThat(sink.stopped).isEqualTo("QUEUE_FULL");assertThat(sink.received).isEmpty(); + }finally{c.close();} + } + @Test void diagnosticLockContentionNeverWaitsOnCollector()throws Exception{ + Fake sink=new Fake();var c=capture(sink);c.pump();var field=LingxingQuantityCapture.class.getDeclaredField("intake");field.setAccessible(true);var lock=(java.util.concurrent.locks.ReentrantLock)field.get(c); + var pool=Executors.newSingleThreadExecutor();lock.lock();try{var observation=pool.submit(()->{try{c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);}catch(Exception e){throw new RuntimeException(e);}});observation.get(500,TimeUnit.MILLISECONDS);assertThat(c.stopReason()).isEqualTo("OBSERVER_FAILED");}finally{lock.unlock();pool.shutdownNow();c.close();} + } + record TestClose(LingxingQuantityCapture value)implements AutoCloseable{public void close(){value.close();}} } diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java index 6fb44e246..2592d581b 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSinkTest.java @@ -1,65 +1,32 @@ package com.baiyue.integration.provider.lingxing; - -import com.baiyue.integration.config.LingxingEgressProperties; -import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.time.Instant; +import java.nio.ByteBuffer; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.Flow; import static org.assertj.core.api.Assertions.*; - class LingxingQuantityHttpSinkTest { - @Test void exactContractAuthenticationAndNoRetryWhenReceiverStops() throws Exception { - var calls = new AtomicInteger(); var body = new AtomicReference(); var header = new AtomicReference(); - var outcome = new AtomicReference<>("ACCEPTED"); - var reason = new AtomicReference<>("null"); - HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders", exchange -> { - calls.incrementAndGet(); body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); - header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")); - byte[] response = ("{\"code\":\"200\",\"data\":{\"outcome\":\"" + outcome.get() + "\",\"stopReason\":" + reason.get() + "}}") - .getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, response.length); exchange.getResponseBody().write(response); exchange.close(); - }); server.start(); - try { - var props = new LingxingEgressProperties(CollectionMode.OFF, "test", null, null, null, - URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false); - var mapper = new ObjectMapper().registerModule(new JavaTimeModule()) - .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - String token = "synthetic-internal-token-000000000000"; - var sink = new LingxingQuantityHttpSink(props, mapper, token); - var obs = new LingxingQuantityEvidence.Observation("99", null, Instant.parse("2026-10-13T00:00:00Z"), "attempt", true); - var evidence = new LingxingQuantityEvidence("diagnostic", "account", "root", "collection", "W", "C", "D", obs, obs, - List.of(new LingxingQuantityEvidence.Line("S", true, true, null, null, null))); - sink.accept(evidence); - var json = mapper.readTree(body.get()); - assertThat(header.get()).isEqualTo(token); assertThat(json.path("diagnosticSessionId").asText()).isEqualTo("diagnostic"); - assertThat(json.path("listObservation").path("observedAt").asText()).isEqualTo("2026-10-13T00:00:00Z"); - assertThat(json.path("skus").get(0).path("forecastQty").isNull()).isTrue(); - assertThat(json.has("skuSetEqual")).isFalse(); assertThat(json.has("forecastActualDifference")).isFalse(); - outcome.set("STOPPED"); assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOf(IllegalStateException.class); - assertThat(calls.get()).isEqualTo(2); - for (String stopReason : List.of("FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED")) { - outcome.set("ACCEPTED"); reason.set("\"" + stopReason + "\""); - assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying( - LingxingQuantityEvidence.ReceiverStopped.class, stopped -> { - assertThat(stopped.reason()).isEqualTo(stopReason); - assertThat(stopped.orderStored()).isTrue(); - }); - } - outcome.set("STOPPED"); reason.set("\"DISABLED\""); - assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying( - LingxingQuantityEvidence.ReceiverStopped.class, stopped -> { - assertThat(stopped.reason()).isEqualTo("DISABLED"); - assertThat(stopped.orderStored()).isFalse(); - }); - assertThat(calls.get()).isEqualTo(5); - } finally { server.stop(0); } - } + @Test void internalWireContractAuthenticatesAndDoesNotRetry()throws Exception{ + var server=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0);var calls=new java.util.concurrent.atomic.AtomicInteger();var header=new java.util.concurrent.atomic.AtomicReference();var path=new java.util.concurrent.atomic.AtomicReference(); + server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/",exchange->{calls.incrementAndGet();header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));path.set(exchange.getRequestURI().getPath());exchange.getRequestBody().readAllBytes();byte[] payload="{\"code\":\"200\",\"data\":null}".getBytes(java.nio.charset.StandardCharsets.UTF_8);exchange.sendResponseHeaders(200,payload.length);exchange.getResponseBody().write(payload);exchange.close();});server.start(); + try{ + var props=new com.baiyue.integration.config.LingxingEgressProperties(com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode.OFF,"test",null,null,null,java.net.URI.create("http://127.0.0.1:"+server.getAddress().getPort()),null,null,false); + String token="synthetic-internal-token-000000000000";var sink=new LingxingQuantityHttpSink(props,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(),token); + assertThat(sink.status(new LingxingQuantityEvidence.Status("account","producer",true,false))).isNull();assertThat(header.get()).isEqualTo(token);assertThat(path.get()).endsWith("/status");assertThat(calls.get()).isEqualTo(1); + sink.stop(new LingxingQuantityEvidence.Stop("diag","account","producer","INCOMPLETE"));assertThat(path.get()).endsWith("/stop");assertThat(calls.get()).isEqualTo(2); + }finally{server.stop(0);} + } + @Test void bodyLimitCancelsBeforeRetainingOversizedResponse(){ + var body=new LingxingQuantityHttpSink.LimitedBody(16);boolean[] cancelled={false};body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){cancelled[0]=true;}}); + body.onNext(List.of(ByteBuffer.wrap(new byte[8])));body.onNext(List.of(ByteBuffer.wrap(new byte[9]))); + assertThat(cancelled[0]).isTrue();assertThat(body.getBody().toCompletableFuture()).isCompletedExceptionally(); + } + @Test void hundredFullUnicodeKeysFitTheBoundedResponse()throws Exception{ + var keys=new java.util.ArrayList();for(int i=0;i<100;i++)keys.add(new LingxingQuantityEvidence.Key("倉".repeat(64),"客".repeat(128),"単".repeat(125)+i,false)); + byte[] payload=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(java.util.Map.of("code","200","data",java.util.Map.of("selected",keys))); + assertThat(payload.length).isGreaterThan(65536).isLessThan(262144); + var body=new LingxingQuantityHttpSink.LimitedBody(262144);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(payload)));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).isEqualTo(payload); + } + @Test void boundedSuccessfulBodyIsExact(){ + var body=new LingxingQuantityHttpSink.LimitedBody(16);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(new byte[]{1,2})));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).containsExactly(1,2); + } } diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index 29a4dd64a..d5b1deda4 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -55,7 +55,7 @@ class LingxingWmsClientTest { @Test void observerFailureCannotChangeBusinessResponseOrIssueExtraRequests() { var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); org.mockito.Mockito.doThrow(new IllegalStateException("diagnostic failure")).when(observer) - .observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), + .observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(Operation.class), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); var c = client(); c.setQuantityCapture(observer); assertThat(c.queryPage(query(1, 1), context()).records()).hasSize(1); @@ -83,7 +83,10 @@ class LingxingWmsClientTest { } @Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception { response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", ""))); - var result = client().locate(new LocateRequest("DO-DEMO", 1, 1), context()); + var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); + var c = client(); c.setQuantityCapture(observer); + var result = c.locate(new LocateRequest("DO-DEMO", 1, 1), context()); + org.mockito.Mockito.verifyNoInteractions(observer); var data = mapper.readTree(body.get()).path("data"); assertThat(data.size()).isEqualTo(3); assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java index 832548d4e..0380816ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java @@ -1,22 +1,16 @@ package com.baiyue.lastleg.selfoperated.analytics.diagnostic; - import java.math.BigDecimal; import java.time.Instant; import java.util.List; - -/** Diagnostic evidence only; never consumed by sales publication. */ public final class QuantityDiagnosticContract { - private QuantityDiagnosticContract() { } - public record Order(String diagnosticSessionId, String collectionSessionId, String rootTaskId, - String sourceAccount, String warehouseCode, String ownerCode, String orderNo, - Observation listObservation, Observation detailObservation, List skus) { } - public record Observation(String status, String outboundTime, Instant observedAt, String attemptId, - Boolean skuSetComplete) { } - public record Sku(String sku, BigDecimal listQty, BigDecimal forecastQty, BigDecimal actualQty, - Boolean listPresent, Boolean detailPresent) { } - public enum Comparison { - SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE - } - public record Receipt(String outcome, Comparison comparison, String stopReason, - int acceptedOrders, int acceptedSkuRows, long acceptedBytes) { } + private QuantityDiagnosticContract() {} + public record Arm(String diagnosticSessionId,String sourceAccount,List warehouseCodes,String ownerCode,String excludedRootTaskId) {} + public record Status(String sourceAccount,String producerId,boolean attach,boolean drained) {} + public record Stop(String diagnosticSessionId,String sourceAccount,String producerId,String reason) {} + public record Observation(String status,String outboundTime,Instant observedAt,String attemptId,Boolean skuSetComplete) {} + public record Sku(String sku,BigDecimal listQty,BigDecimal forecastQty,BigDecimal actualQty) {} + public record Event(String diagnosticSessionId,String sourceAccount,String producerId,String rootTaskId,String collectionSessionId,String operation,String warehouseCode,String ownerCode,String orderNo,String side,Observation observation,List skus) {} + public record Key(String warehouseCode,String ownerCode,String orderNo,boolean paired) {} + public record View(String diagnosticSessionId,String sourceAccount,String rootTaskId,String collectionSessionId,String state,String stopReason,Instant startsAt,Instant expiresAt,Instant bindBefore,List warehouseCodes,String ownerCode,String excludedRootTaskId,int candidates,int pairedOrders,int retainedRows,long retainedBytes,boolean deliveryUncertain,List selected) {} + public record Receipt(String outcome,String comparison,View session) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java index e189db304..a0737bee9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java @@ -24,20 +24,35 @@ public class QuantityDiagnosticController { this.json = json.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); } - @PostMapping(value = "/orders", consumes = "application/json") - public ApiResponse accept( - @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody byte[] body) { + @PostMapping(value="/arm",consumes="application/json") + public ApiResponse arm(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){ + return run(()->service.arm(read(token,request,QuantityDiagnosticContract.Arm.class))); + } + @PostMapping(value="/status",consumes="application/json") + public ApiResponse status(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){ + return run(()->service.status(read(token,request,QuantityDiagnosticContract.Status.class))); + } + @PostMapping(value="/observe",consumes="application/json") + public ApiResponse observe(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){ + return run(()->service.observe(read(token,request,QuantityDiagnosticContract.Event.class))); + } + @PostMapping(value="/stop",consumes="application/json") + public ApiResponse stop(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){ + return run(()->service.stop(read(token,request,QuantityDiagnosticContract.Stop.class))); + } + private T read(String token,jakarta.servlet.http.HttpServletRequest request,Class type){ guard.verify(token); - if (body.length > QuantityDiagnosticPolicy.MAX_REQUEST_BYTES) - throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_REQUEST_TOO_LARGE"); - QuantityDiagnosticContract.Order order; - try { order = json.readValue(body, QuantityDiagnosticContract.Order.class); } - catch (IOException e) { throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_JSON_INVALID"); } - try { return ApiResponse.success(service.accept(order)); } - catch (DataAccessException | TransactionException e) { - // No SQL, request payload or credentials are returned to the caller. - throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "DIAGNOSTIC_STORAGE_UNAVAILABLE"); - } + int limit=QuantityDiagnosticPolicy.MAX_REQUEST_BYTES; + if(request.getContentLengthLong()>limit)throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_REQUEST_TOO_LARGE"); + try { + byte[] body=request.getInputStream().readNBytes(limit+1); + if(body.length>limit)throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_REQUEST_TOO_LARGE"); + return json.readValue(body,type); + }catch(IOException e){throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_JSON_INVALID");} + } + private ApiResponse run(java.util.function.Supplier action){ + try{return ApiResponse.success(action.get());} + catch(DataAccessException|TransactionException e){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"DIAGNOSTIC_STORAGE_UNAVAILABLE");} } @ExceptionHandler(ResponseStatusException.class) public ResponseEntity> rejected(ResponseStatusException error) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java index 0c1ddd085..c8ac75384 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java @@ -1,89 +1,33 @@ package com.baiyue.lastleg.selfoperated.analytics.diagnostic; - import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*; import java.math.BigDecimal; -import java.time.Duration; import java.time.Instant; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; +import java.util.*; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; - final class QuantityDiagnosticPolicy { - static final int MAX_ORDERS = 100, MAX_ROWS = 2000, MAX_ORDER_ROWS = 200; - static final int MAX_BYTES = 2 * 1024 * 1024, MAX_REQUEST_BYTES = 256 * 1024; - private QuantityDiagnosticPolicy() { } - static ResponseStatusException invalid(String code) { - return new ResponseStatusException(HttpStatus.BAD_REQUEST, code); - } - static void text(String value, int maximum) { - if (value == null || value.isBlank() || value.length() > maximum - || value.codePoints().anyMatch(Character::isISOControl)) throw invalid("DIAGNOSTIC_FIELD_INVALID"); - } - static void settings(QuantityDiagnosticProperties p) { - text(p.diagnosticSessionId(), 128); text(p.collectionSessionId(), 128); - text(p.rootTaskId(), 128); text(p.sourceAccount(), 64); - if (p.startsAt() == null || p.expiresAt() == null || !p.expiresAt().isAfter(p.startsAt()) - || Duration.between(p.startsAt(), p.expiresAt()).compareTo(Duration.ofMinutes(30)) > 0 - || p.warehouseCodes() == null || p.warehouseCodes().isEmpty() || p.warehouseCodes().size() > 20) - throw invalid("DIAGNOSTIC_CONFIGURATION_INVALID"); - p.warehouseCodes().forEach(w -> text(w, 64)); - } - static Order normalize(Order r, QuantityDiagnosticProperties p, Instant now) { - if (r == null) throw invalid("DIAGNOSTIC_ORDER_REQUIRED"); - if (!p.diagnosticSessionId().equals(r.diagnosticSessionId()) - || !p.collectionSessionId().equals(r.collectionSessionId()) || !p.rootTaskId().equals(r.rootTaskId()) - || !p.sourceAccount().equals(r.sourceAccount()) || !p.warehouseCodes().contains(r.warehouseCode())) - throw invalid("DIAGNOSTIC_SCOPE_MISMATCH"); - text(r.ownerCode(), 128); text(r.orderNo(), 128); - observation(r.listObservation(), p, now); observation(r.detailObservation(), p, now); - if (r.skus() == null || r.skus().isEmpty() || r.skus().size() > MAX_ORDER_ROWS) - throw invalid("DIAGNOSTIC_SKU_LIMIT"); - var seen = new HashSet(); - for (Sku s : r.skus()) { - if (s == null) throw invalid("DIAGNOSTIC_SKU_REQUIRED"); - text(s.sku(), 128); - if (!seen.add(s.sku())) throw invalid("DIAGNOSTIC_DUPLICATE_SKU"); - if (s.listPresent() == null || s.detailPresent() == null || (!s.listPresent() && !s.detailPresent())) - throw invalid("DIAGNOSTIC_PRESENCE_INVALID"); - if ((!s.listPresent() && s.listQty() != null) - || (!s.detailPresent() && (s.forecastQty() != null || s.actualQty() != null))) - throw invalid("DIAGNOSTIC_ABSENT_QUANTITY"); - quantity(s.listQty()); quantity(s.forecastQty()); quantity(s.actualQty()); - } - List skus = r.skus().stream().sorted(Comparator.comparing(Sku::sku)) - .map(s -> new Sku(s.sku(), decimal(s.listQty()), decimal(s.forecastQty()), decimal(s.actualQty()), - s.listPresent(), s.detailPresent())).toList(); - return new Order(r.diagnosticSessionId(), r.collectionSessionId(), r.rootTaskId(), r.sourceAccount(), - r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), r.detailObservation(), skus); - } - private static void observation(Observation o, QuantityDiagnosticProperties p, Instant now) { - if (o == null || o.observedAt() == null || o.skuSetComplete() == null) - throw invalid("DIAGNOSTIC_OBSERVATION_REQUIRED"); - text(o.attemptId(), 128); - if (o.status() != null) text(o.status(), 32); - if (o.outboundTime() != null) text(o.outboundTime(), 32); - if (o.observedAt().isBefore(p.startsAt()) || o.observedAt().isAfter(p.expiresAt()) - || o.observedAt().isAfter(now)) throw invalid("DIAGNOSTIC_OBSERVATION_OUTSIDE_SESSION"); - } - private static BigDecimal decimal(BigDecimal v) { return v == null ? null : v.stripTrailingZeros(); } - private static void quantity(BigDecimal v) { - if (v != null && (v.signum() < 0 || v.scale() > 6 || v.precision() - v.scale() > 32)) - throw invalid("DIAGNOSTIC_QUANTITY_INVALID"); - } - static boolean forecastDifference(Order r) { - return r.skus().stream().anyMatch(s -> s.forecastQty() != null && s.actualQty() != null - && s.forecastQty().compareTo(s.actualQty()) != 0); - } - static Comparison compare(Order r) { - if (!r.listObservation().skuSetComplete() || !r.detailObservation().skuSetComplete() - || r.skus().stream().anyMatch(s -> !s.listPresent() || !s.detailPresent())) - return Comparison.SKU_SET_MISMATCH; - if (r.skus().stream().anyMatch(s -> s.listQty() == null || s.forecastQty() == null || s.actualQty() == null)) - return Comparison.QUANTITY_UNKNOWN; - if (forecastDifference(r)) return Comparison.FORECAST_ACTUAL_DIFFERENCE; - return r.skus().stream().allMatch(s -> s.listQty().compareTo(s.actualQty()) == 0) - ? Comparison.ALL_EQUAL : Comparison.LIST_QUANTITY_DIFFERENCE; - } + static final int MAX_ORDERS=100,MAX_ROWS=2000,MAX_BYTES=2097152,MAX_REQUEST_BYTES=262144; + private QuantityDiagnosticPolicy() {} + static ResponseStatusException invalid(String code){return new ResponseStatusException(HttpStatus.BAD_REQUEST,code);} + static void require(boolean value,String code){if(!value)throw invalid(code);} + static void text(String s,int max){require(s!=null&&!s.isBlank()&&s.length()<=max&&s.codePoints().noneMatch(Character::isISOControl),"DIAGNOSTIC_FIELD_INVALID");} + static Event normalize(Event e,Instant now){ + require(e!=null,"DIAGNOSTIC_EVENT_REQUIRED");text(e.diagnosticSessionId(),128);text(e.sourceAccount(),64);text(e.rootTaskId(),128);text(e.collectionSessionId(),128);text(e.warehouseCode(),64);text(e.ownerCode(),128);text(e.orderNo(),128); + text(e.producerId(),128);require("LIST".equals(e.side())||"DETAIL".equals(e.side()),"DIAGNOSTIC_SIDE_INVALID");require(("LIST".equals(e.side())?"OUTBOUND_QUERY":"OUTBOUND_DETAIL").equals(e.operation()),"DIAGNOSTIC_OPERATION_INVALID");require(e.observation()!=null&&e.observation().observedAt()!=null&&e.observation().skuSetComplete()!=null,"DIAGNOSTIC_OBSERVATION_REQUIRED");text(e.observation().attemptId(),128); + if(e.observation().status()!=null)text(e.observation().status(),32);if(e.observation().outboundTime()!=null)text(e.observation().outboundTime(),64); + require(!e.observation().observedAt().isAfter(now),"DIAGNOSTIC_FUTURE_OBSERVATION");require(e.skus()!=null&&(!e.skus().isEmpty()||!e.observation().skuSetComplete())&&e.skus().size()<=200,"DIAGNOSTIC_SKU_LIMIT"); + var seen=new HashSet();var skus=new ArrayList();for(var s:e.skus()){require(s!=null,"DIAGNOSTIC_SKU_REQUIRED");text(s.sku(),128);require(seen.add(s.sku()),"DIAGNOSTIC_DUPLICATE_SKU"); + for(var n:Arrays.asList(s.listQty(),s.forecastQty(),s.actualQty()))if(n!=null)require(n.signum()>=0&&n.scale()<=6&&n.precision()-n.scale()<=32,"DIAGNOSTIC_QUANTITY_INVALID"); + require("LIST".equals(e.side())?s.forecastQty()==null&&s.actualQty()==null:s.listQty()==null,"DIAGNOSTIC_SIDE_FIELDS");skus.add(new Sku(s.sku(),decimal(s.listQty()),decimal(s.forecastQty()),decimal(s.actualQty()))); + }skus.sort(Comparator.comparing(Sku::sku));return new Event(e.diagnosticSessionId(),e.sourceAccount(),e.producerId(),e.rootTaskId(),e.collectionSessionId(),e.operation(),e.warehouseCode(),e.ownerCode(),e.orderNo(),e.side(),e.observation(),List.copyOf(skus)); + } + static BigDecimal decimal(BigDecimal n){return n==null?null:n.stripTrailingZeros();} + static boolean different(BigDecimal a,BigDecimal b){return a!=null&&b!=null&&a.compareTo(b)!=0;} + static String compare(Event list,Event detail){ + var l=new TreeMap();var d=new TreeMap();list.skus().forEach(s->l.put(s.sku(),s));detail.skus().forEach(s->d.put(s.sku(),s)); + boolean incomplete=!Boolean.TRUE.equals(list.observation().skuSetComplete())||!Boolean.TRUE.equals(detail.observation().skuSetComplete())||!l.keySet().equals(d.keySet()); + var keys=new HashSet(l.keySet());keys.addAll(d.keySet());boolean difference=false; + for(String key:keys){var a=l.get(key);var b=d.get(key);BigDecimal x=a==null?null:a.listQty(),y=b==null?null:b.forecastQty(),z=b==null?null:b.actualQty();difference|=different(x,y)||different(x,z)||different(y,z);incomplete|=x==null||y==null||z==null;} + return incomplete?"INCONCLUSIVE":difference?"QUANTITY_DIFFERENCE":"ALL_EQUAL"; + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java index d65207e9e..630aa6d39 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java @@ -1,11 +1,6 @@ package com.baiyue.lastleg.selfoperated.analytics.diagnostic; - -import java.time.Instant; import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.DefaultValue; - @ConfigurationProperties("baiyue.selfop.quantity-diagnostics") -public record QuantityDiagnosticProperties(@DefaultValue("false") boolean enabled, - String diagnosticSessionId, String collectionSessionId, String rootTaskId, String sourceAccount, - List warehouseCodes, Instant startsAt, Instant expiresAt) { } +public record QuantityDiagnosticProperties(@DefaultValue("false") boolean enabled, String sourceAccount, List warehouseCodes) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java index c74396862..995d78962 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java @@ -2,124 +2,218 @@ package com.baiyue.lastleg.selfoperated.analytics.diagnostic; import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*; import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticPolicy.*; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy; import com.fasterxml.jackson.databind.ObjectMapper; +import com.zaxxer.hikari.HikariDataSource; +import jakarta.annotation.PreDestroy; +import javax.sql.DataSource; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Clock; -import java.time.Instant; -import java.util.HexFormat; -import java.util.List; +import java.time.*; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.function.Supplier; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.server.ResponseStatusException; -/** One short transaction per paired order; only the two diagnostic tables are written. */ +/** Serializes diagnostic state only; never locks or updates collection/business rows. */ @Service public class QuantityDiagnosticService { private final JdbcTemplate jdbc; private final TransactionTemplate tx; private final ObjectMapper json; - private final QuantityDiagnosticProperties properties; + private final QuantityDiagnosticProperties config; private final Clock clock; + private final Semaphore admission = new Semaphore(1); + private HikariDataSource pool; @Autowired - public QuantityDiagnosticService(JdbcTemplate jdbc, PlatformTransactionManager manager, - ObjectMapper json, QuantityDiagnosticProperties properties) { - this(jdbc, manager, json, properties, Clock.systemUTC()); - } - QuantityDiagnosticService(JdbcTemplate jdbc, PlatformTransactionManager manager, - ObjectMapper json, QuantityDiagnosticProperties properties, Clock clock) { - this.jdbc = jdbc; this.json = json.copy(); this.properties = properties; this.clock = clock; - this.tx = new TransactionTemplate(manager); this.tx.setTimeout(3); - } - public Receipt accept(Order input) { - if (!properties.enabled()) return new Receipt("STOPPED", null, "DISABLED", 0, 0, 0); - settings(properties); - Order order = normalize(input, properties, clock.instant()); - String payload = encode(order); - int bytes = payload.getBytes(StandardCharsets.UTF_8).length; - if (bytes > MAX_REQUEST_BYTES) throw invalid("DIAGNOSTIC_REQUEST_TOO_LARGE"); - String key = hash(encode(List.of(order.sourceAccount(), order.warehouseCode(), order.ownerCode(), order.orderNo()))); - String payloadHash = hash(payload); - String configuration = hash(encode(List.of("v1", properties.diagnosticSessionId(), properties.collectionSessionId(), - properties.rootTaskId(), properties.sourceAccount(), properties.warehouseCodes().stream().sorted().distinct().toList(), - properties.startsAt(), properties.expiresAt(), MAX_ORDERS, MAX_ROWS, MAX_BYTES))); - return tx.execute(status -> persist(order, key, payload, payloadHash, bytes, configuration)); - } - private Receipt persist(Order order, String key, String payload, String payloadHash, int bytes, String configuration) { - String id = order.diagnosticSessionId(); - // Duplicate insert acquires the same durable session lock, including first-request races. - jdbc.update(""" - INSERT INTO ll_selfop_quantity_diagnostic_session - (diagnostic_session_id, configuration_hash, source_account, collection_session_id, root_task_id, - starts_at, expires_at, accepted_orders, accepted_sku_rows, accepted_bytes) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0) - ON DUPLICATE KEY UPDATE diagnostic_session_id = diagnostic_session_id - """, id, configuration, order.sourceAccount(), order.collectionSessionId(), order.rootTaskId(), - properties.startsAt().toString(), properties.expiresAt().toString()); - Session session = jdbc.queryForObject(""" - SELECT configuration_hash, accepted_orders, accepted_sku_rows, accepted_bytes, stop_reason - FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id = ? FOR UPDATE - """, (rs, n) -> new Session(rs.getString(1), rs.getInt(2), rs.getInt(3), rs.getLong(4), rs.getString(5)), id); - if (session == null || !configuration.equals(session.configurationHash())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "DIAGNOSTIC_SESSION_CONFIGURATION_CHANGED"); - var existing = jdbc.query(""" - SELECT payload_hash, comparison FROM ll_selfop_quantity_diagnostic_order - WHERE diagnostic_session_id = ? AND order_key_hash = ? - """, (rs, n) -> new Existing(rs.getString(1), Comparison.valueOf(rs.getString(2))), id, key); - if (!existing.isEmpty()) { - if (!payloadHash.equals(existing.get(0).payloadHash())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "DIAGNOSTIC_ORDER_CONFLICT"); - return session.receipt("DUPLICATE", existing.get(0).comparison(), session.stopReason()); + public QuantityDiagnosticService(DataSource source, ObjectMapper json, QuantityDiagnosticProperties config) { + this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.config=config; this.clock=Clock.systemUTC(); + // Separate, bounded connection acquisition. Diagnostics cannot exhaust the collection pool. + if(config.enabled()) { + try { + HikariDataSource base=source.unwrap(HikariDataSource.class); + pool=new HikariDataSource(); pool.setJdbcUrl(base.getJdbcUrl()); pool.setUsername(base.getUsername()); + pool.setPassword(base.getPassword()); pool.setDataSourceProperties(base.getDataSourceProperties()); + pool.setMaximumPoolSize(1); pool.setMinimumIdle(0); pool.setConnectionTimeout(250); + pool.setValidationTimeout(250); pool.setInitializationFailTimeout(-1); pool.setPoolName("quantity-diagnostic"); + } catch(Exception unavailable) { if(pool!=null)pool.close(); pool=null; } } - if (session.stopReason() != null) return session.receipt("STOPPED", null, session.stopReason()); - Instant now = clock.instant(); - if (now.isBefore(properties.startsAt())) return session.receipt("STOPPED", null, "NOT_STARTED"); - if (!now.isBefore(properties.expiresAt())) return stop(id, session, "EXPIRED"); - if (session.orders() >= MAX_ORDERS || session.rows() + order.skus().size() > MAX_ROWS - || session.bytes() + bytes > MAX_BYTES) return stop(id, session, "LIMIT_REACHED"); - Comparison comparison = compare(order); - jdbc.update(""" - INSERT INTO ll_selfop_quantity_diagnostic_order - (diagnostic_session_id, order_key_hash, source_account, warehouse_code, owner_code, order_no, - payload_hash, comparison, sku_rows, payload_bytes, evidence_json, received_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, id, key, order.sourceAccount(), order.warehouseCode(), order.ownerCode(), order.orderNo(), - payloadHash, comparison.name(), order.skus().size(), bytes, payload, now.toString()); - int orders = session.orders() + 1, rows = session.rows() + order.skus().size(); - long totalBytes = session.bytes() + bytes; - String reason = forecastDifference(order) ? "FORECAST_ACTUAL_DIFFERENCE" - : orders == MAX_ORDERS || rows == MAX_ROWS || totalBytes == MAX_BYTES ? "LIMIT_REACHED" : null; - int updated = jdbc.update(""" - UPDATE ll_selfop_quantity_diagnostic_session - SET accepted_orders = ?, accepted_sku_rows = ?, accepted_bytes = ?, stop_reason = ? - WHERE diagnostic_session_id = ? - """, orders, rows, totalBytes, reason, id); - if (updated != 1) throw new IllegalStateException("DIAGNOSTIC_SESSION_UPDATE_FAILED"); - return new Receipt("ACCEPTED", comparison, reason, orders, rows, totalBytes); + jdbc=pool==null?null:new JdbcTemplate(pool); + tx=pool==null?null:new TransactionTemplate(new DataSourceTransactionManager(pool)); + if(tx!=null) { tx.setTimeout(3); jdbc.setQueryTimeout(2); } } - private Receipt stop(String id, Session session, String reason) { - jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET stop_reason = ? WHERE diagnostic_session_id = ?", reason, id); - return session.receipt("STOPPED", null, reason); + QuantityDiagnosticService(JdbcTemplate jdbc,PlatformTransactionManager manager,ObjectMapper json, + QuantityDiagnosticProperties config,Clock clock) { + this.jdbc=jdbc;this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.config=config;this.clock=clock;tx=new TransactionTemplate(manager);tx.setTimeout(3); } - private String encode(Object value) { - try { return json.writeValueAsString(value); } - catch (JsonProcessingException e) { throw invalid("DIAGNOSTIC_ENCODING_FAILED"); } + @PreDestroy public void close(){if(pool!=null)pool.close();} + private T guarded(Supplier action){ + require(config.enabled(),"DIAGNOSTIC_DISABLED"); + if(tx==null)throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"DIAGNOSTIC_DATASOURCE_UNAVAILABLE"); + if(!admission.tryAcquire())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"DIAGNOSTIC_BUSY"); + try{return tx.execute(s->action.get());}finally{admission.release();} } - private static String hash(String value) { - try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } - catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + private void account(String account){text(account,64);require(account.equals(config.sourceAccount()),"DIAGNOSTIC_ACCOUNT_REJECTED");} + public View arm(Arm request){ + require(request!=null,"DIAGNOSTIC_ARM_REQUIRED");text(request.diagnosticSessionId(),128);account(request.sourceAccount()); + text(request.ownerCode(),128);if(request.excludedRootTaskId()!=null)text(request.excludedRootTaskId(),128); + require(request.warehouseCodes()!=null&&!request.warehouseCodes().isEmpty()&&request.warehouseCodes().size()<=8,"DIAGNOSTIC_SCOPE_REJECTED"); + request.warehouseCodes().forEach(w->text(w,64)); + require(config.warehouseCodes()!=null&&config.warehouseCodes().containsAll(request.warehouseCodes()),"DIAGNOSTIC_SCOPE_REJECTED"); + Arm scope=new Arm(request.diagnosticSessionId(),request.sourceAccount(),request.warehouseCodes().stream().sorted().distinct().toList(),request.ownerCode(),request.excludedRootTaskId()); + return guarded(()->{ + Session existing=load(scope.diagnosticSessionId()); + if(existing!=null){require(existing.scope.equals(scope),"DIAGNOSTIC_ARM_CONFLICT");advance(existing);save(existing);return view(existing);} + // Read-only arm snapshot: unfinished automatic roots that have actually started. + var current=jdbc.queryForList("SELECT id FROM ll_selfop_sync_task WHERE source_account=? AND task_role='ACCOUNT_DISCOVERY' AND task_type='BACKFILL' AND execution_purpose='BUSINESS' AND requested_by='SELFOP_CONTINUOUS_PLANNER' AND (status IN ('RUNNING','RETRY_WAIT') OR (status='READY' AND attempt_count>0)) ORDER BY id LIMIT 2",String.class,scope.sourceAccount()); + require(current.size()<=1,"DIAGNOSTIC_CURRENT_ROOT_AMBIGUOUS"); + String excluded=current.isEmpty()?null:current.get(0); + require(Objects.equals(excluded,scope.excludedRootTaskId()),"DIAGNOSTIC_CURRENT_ROOT_CHANGED"); + require(excluded==null||eligible(root(excluded),scope.sourceAccount()),"DIAGNOSTIC_EXCLUDED_ROOT_INVALID"); + var active=jdbc.queryForList("SELECT diagnostic_session_id FROM ll_selfop_quantity_diagnostic_session WHERE source_account=? AND active_slot=1",String.class,scope.sourceAccount()); + if(!active.isEmpty()){Session s=load(active.get(0));advance(s);save(s);require(terminal(s),"DIAGNOSTIC_ALREADY_ARMED");} + Session s=new Session();s.scope=scope;s.armedAt=clock.instant();s.bindBefore=s.armedAt.plusSeconds(21600);s.expiresAt=s.armedAt.plusSeconds(36000);s.state="ARMED"; + // Account+active_slot unique constraint is the cross-instance arm arbitration point. + jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_session(diagnostic_session_id,configuration_hash,source_account,starts_at,expires_at,active_slot,state_json) VALUES(?,?,?,?,?,1,?)",scope.diagnosticSessionId(),hash(encode(scope)),scope.sourceAccount(),s.armedAt.toString(),s.expiresAt.toString(),encode(s)); + return view(s); + }); } - private record Existing(String payloadHash, Comparison comparison) { } - private record Session(String configurationHash, int orders, int rows, long bytes, String stopReason) { - Receipt receipt(String outcome, Comparison comparison, String reason) { - return new Receipt(outcome, comparison, reason, orders, rows, bytes); - } + public View status(Status request){ + require(request!=null,"DIAGNOSTIC_STATUS_REQUIRED");account(request.sourceAccount());text(request.producerId(),128); + return guarded(()->{ + var ids=jdbc.queryForList("SELECT diagnostic_session_id FROM ll_selfop_quantity_diagnostic_session WHERE source_account=? AND state_json IS NOT NULL ORDER BY active_slot DESC, starts_at DESC LIMIT 1",String.class,request.sourceAccount()); + if(ids.isEmpty())return null; + Session s=load(ids.get(0));advance(s); + if(!terminal(s)){ + if(s.producerId==null)s.producerId=request.producerId(); + else if(!s.producerId.equals(request.producerId())){ + require(request.attach(),"DIAGNOSTIC_PRODUCER_FENCED"); + // Recovery retains persisted candidates, but cannot claim the lost in-memory tail was complete. + s.deliveryUncertain=true;s.producerId=request.producerId(); + } + if("DRAINING".equals(s.state)&&request.drained())finish(s); + } + save(s);return view(s); + }); + } + public View stop(Stop request){ + require(request!=null,"DIAGNOSTIC_STOP_REQUIRED");account(request.sourceAccount());text(request.diagnosticSessionId(),128);text(request.producerId(),128); + require(Set.of("INCOMPLETE","QUEUE_FULL","OBSERVER_FAILED","SHUTDOWN").contains(request.reason()),"DIAGNOSTIC_STOP_REASON"); + return guarded(()->{Session s=load(request.diagnosticSessionId());require(s!=null&&s.scope.sourceAccount().equals(request.sourceAccount()),"DIAGNOSTIC_SESSION_UNKNOWN"); + require(Objects.equals(s.producerId,request.producerId()),"DIAGNOSTIC_PRODUCER_FENCED"); + if(!terminal(s)){s.deliveryUncertain=true;end(s,"INCOMPLETE");}save(s);return view(s);}); + } + public Receipt observe(Event input){ + Event e=normalize(input,clock.instant());account(e.sourceAccount());String payload=encode(e);int bytes=size(payload); + require(bytes<=MAX_REQUEST_BYTES,"DIAGNOSTIC_REQUEST_TOO_LARGE"); + return guarded(()->{ + Session s=load(e.diagnosticSessionId());require(s!=null&&s.scope.sourceAccount().equals(e.sourceAccount()),"DIAGNOSTIC_SESSION_UNKNOWN"); + String key=hash(encode(List.of(e.sourceAccount(),e.warehouseCode(),e.ownerCode(),e.orderNo()))); + var completed=jdbc.query("SELECT evidence_json,comparison FROM ll_selfop_quantity_diagnostic_order WHERE diagnostic_session_id=? AND order_key_hash=?",(r,n)->new String[]{r.getString(1),r.getString(2)},e.diagnosticSessionId(),key); + if(!completed.isEmpty()){ + Pair pair=decode(completed.get(0)[0],Pair.class);Event original="LIST".equals(e.side())?pair.list:pair.detail; + require(encode(original).equals(payload),"DIAGNOSTIC_OBSERVATION_CONFLICT"); + return new Receipt("DUPLICATE",completed.get(0)[1],view(s)); + } + var pending=jdbc.query("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_pending WHERE diagnostic_session_id=? AND order_key_hash=?",(r,n)->decode(r.getString(1),Event.class),e.diagnosticSessionId(),key); + if("LIST".equals(e.side())&&!pending.isEmpty()){ + require(encode(pending.get(0)).equals(payload),"DIAGNOSTIC_OBSERVATION_CONFLICT");return new Receipt("DUPLICATE",null,view(s)); + } + // Session lock is the receiver acceptance boundary. Integration enqueue is not acceptance. + advance(s); + if(terminal(s)){save(s);return new Receipt("STOPPED",null,view(s));} + require(Objects.equals(s.producerId,e.producerId()),"DIAGNOSTIC_PRODUCER_FENCED"); + require(!e.observation().observedAt().isBefore(s.armedAt),"DIAGNOSTIC_OLD_OBSERVATION"); + require(s.scope.warehouseCodes().contains(e.warehouseCode())&&("*".equals(s.scope.ownerCode())||s.scope.ownerCode().equals(e.ownerCode())),"DIAGNOSTIC_SCOPE_REJECTED"); + require(("selfop-"+e.rootTaskId()).equals(e.collectionSessionId()),"DIAGNOSTIC_COLLECTION_SESSION_REJECTED"); + SelfopSyncTask task=root(e.rootTaskId());require(eligible(task,e.sourceAccount()),"DIAGNOSTIC_ROOT_REJECTED"); + if(s.rootTaskId==null){ + if(!"LIST".equals(e.side())||e.rootTaskId().equals(s.scope.excludedRootTaskId())||!clock.instant().isBefore(s.bindBefore)||!e.observation().observedAt().isBefore(s.bindBefore))return new Receipt("IGNORED",null,view(s)); + s.rootTaskId=e.rootTaskId();s.collectionSessionId=e.collectionSessionId();s.boundAt=e.observation().observedAt(); + s.expiresAt=s.boundAt.plusSeconds(14400).isBefore(s.expiresAt)?s.boundAt.plusSeconds(14400):s.expiresAt;s.state="BOUND"; + advance(s); + if(terminal(s)){save(s);return new Receipt("STOPPED",null,view(s));} + } + require(s.rootTaskId.equals(e.rootTaskId())&&s.collectionSessionId.equals(e.collectionSessionId()),"DIAGNOSTIC_ROOT_FROZEN"); + if(ended(task)&&!"DRAINING".equals(s.state)){s.state="DRAINING";s.rootEndedAt=clock.instant();} + if("LIST".equals(e.side())&&("DRAINING".equals(s.state)||s.candidates>=MAX_ORDERS)){save(s);return new Receipt("IGNORED",null,view(s));} + if("DETAIL".equals(e.side())&&pending.isEmpty()){save(s);return new Receipt("IGNORED",null,view(s));} + if(s.rootEndedAt!=null&&e.observation().observedAt().isAfter(s.rootEndedAt)){save(s);return new Receipt("IGNORED",null,view(s));} + // Retained bytes/rows are monotonic and count both observations; promotion never refunds quota. + int overhead="LIST".equals(e.side())?20:0; + if(s.rows+e.skus().size()>MAX_ROWS||s.bytes+bytes+overhead>MAX_BYTES){end(s,"INCOMPLETE");save(s);return new Receipt("STOPPED",null,view(s));} + s.rows+=e.skus().size();s.bytes+=bytes+overhead; + String comparison=null; + if("LIST".equals(e.side())){ + s.candidates++; + jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_pending(diagnostic_session_id,order_key_hash,warehouse_code,owner_code,order_no,evidence_json) VALUES(?,?,?,?,?,?)",e.diagnosticSessionId(),key,e.warehouseCode(),e.ownerCode(),e.orderNo(),payload); + if(!Boolean.TRUE.equals(e.observation().skuSetComplete())||e.skus().stream().anyMatch(v->v.listQty()==null)){comparison="INCONCLUSIVE";end(s,comparison);} + }else{ + Event list=pending.get(0);comparison=compare(list,e);String evidence=encode(new Pair(list,e));s.paired++; + require(size(evidence)<=MAX_REQUEST_BYTES,"DIAGNOSTIC_PAIR_TOO_LARGE"); + jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_order(diagnostic_session_id,order_key_hash,source_account,warehouse_code,owner_code,order_no,payload_hash,comparison,sku_rows,payload_bytes,evidence_json,received_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",e.diagnosticSessionId(),key,e.sourceAccount(),e.warehouseCode(),e.ownerCode(),e.orderNo(),hash(evidence),comparison,Math.max(list.skus().size(),e.skus().size()),size(evidence),evidence,clock.instant().toString()); + jdbc.update("DELETE FROM ll_selfop_quantity_diagnostic_pending WHERE diagnostic_session_id=? AND order_key_hash=?",e.diagnosticSessionId(),key); + if(!"ALL_EQUAL".equals(comparison))end(s,comparison); + else if(s.paired==MAX_ORDERS)end(s,s.deliveryUncertain?"INCOMPLETE":"COMPLETED"); + } + save(s);return new Receipt("ACCEPTED",comparison,view(s)); + }); + } + private void advance(Session s){ + if(terminal(s))return; + Instant now=clock.instant(); + if(!now.isBefore(s.expiresAt)){end(s,"INCOMPLETE");return;} + if(s.rootTaskId==null){if(!now.isBefore(s.bindBefore))end(s,"BIND_TIMEOUT");return;} + if(ended(root(s.rootTaskId))&&!"DRAINING".equals(s.state)){s.state="DRAINING";s.rootEndedAt=now;} + } + private void finish(Session s){end(s,s.deliveryUncertain||s.candidates!=s.paired?"INCOMPLETE":s.paired(SelfopSyncTask.class),id); + return rows.isEmpty()?null:rows.get(0); + } + private Session load(String id){ + var sessions=jdbc.query("SELECT state_json,root_task_id,collection_session_id,starts_at,expires_at,accepted_orders,accepted_sku_rows,accepted_bytes,stop_reason FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id=? FOR UPDATE",(r,n)->{ + String metadata=r.getString(1);require(metadata!=null,"DIAGNOSTIC_HISTORICAL_ID_READ_ONLY");Session s=decode(metadata,Session.class); + // Existing columns remain the sole authority for counters, root, deadline and stop reason. + s.rootTaskId=r.getString(2);s.collectionSessionId=r.getString(3);s.boundAt=s.rootTaskId==null?null:Instant.parse(r.getString(4));s.expiresAt=Instant.parse(r.getString(5)); + s.paired=r.getInt(6);s.rows=r.getInt(7);s.bytes=r.getLong(8);s.reason=r.getString(9); + s.state=s.reason!=null?"TERMINAL":s.rootTaskId==null?"ARMED":s.rootEndedAt!=null?"DRAINING":"BOUND";return s; + },id);return sessions.isEmpty()?null:sessions.get(0); + } + private void save(Session s){jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET state_json=?,active_slot=?,collection_session_id=?,root_task_id=?,starts_at=?,expires_at=?,accepted_orders=?,accepted_sku_rows=?,accepted_bytes=?,stop_reason=? WHERE diagnostic_session_id=?",encode(s),terminal(s)?null:1,s.collectionSessionId,s.rootTaskId,(s.boundAt==null?s.armedAt:s.boundAt).toString(),s.expiresAt.toString(),s.paired,s.rows,s.bytes,s.reason,s.scope.diagnosticSessionId());} + private View view(Session s){ + var selected=jdbc.query("SELECT warehouse_code,owner_code,order_no,0 AS paired FROM ll_selfop_quantity_diagnostic_pending WHERE diagnostic_session_id=? UNION ALL SELECT warehouse_code,owner_code,order_no,1 AS paired FROM ll_selfop_quantity_diagnostic_order WHERE diagnostic_session_id=?",(r,n)->new Key(r.getString(1),r.getString(2),r.getString(3),r.getBoolean(4)),s.scope.diagnosticSessionId(),s.scope.diagnosticSessionId()); + return new View(s.scope.diagnosticSessionId(),s.scope.sourceAccount(),s.rootTaskId,s.collectionSessionId,s.state,s.reason,s.boundAt==null?s.armedAt:s.boundAt,s.expiresAt,s.bindBefore,s.scope.warehouseCodes(),s.scope.ownerCode(),s.scope.excludedRootTaskId(),s.candidates,s.paired,s.rows,s.bytes,s.deliveryUncertain,selected); + } + private String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw invalid("DIAGNOSTIC_ENCODING_FAILED");}} + private T decode(String value,Class type){try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("DIAGNOSTIC_STORAGE_INVALID");}} + private static int size(String s){return s.getBytes(StandardCharsets.UTF_8).length;} + private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} + public record Pair(Event list,Event detail){} + public static class Session { + public Arm scope;public String producerId; + public Instant armedAt,bindBefore,rootEndedAt; + public int candidates;public boolean deliveryUncertain; + @com.fasterxml.jackson.annotation.JsonIgnore public String state,reason,rootTaskId,collectionSessionId; + @com.fasterxml.jackson.annotation.JsonIgnore public Instant boundAt,expiresAt; + @com.fasterxml.jackson.annotation.JsonIgnore public int paired,rows; + @com.fasterxml.jackson.annotation.JsonIgnore public long bytes; } } diff --git a/lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql b/lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql index eec9e8ae3..deaac3f6a 100644 --- a/lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql +++ b/lastleg-service/src/main/resources/db/selfop-quantity-diagnostics.sql @@ -2,16 +2,20 @@ -- Run only after explicit TEST SQL approval. Not executed by service startup. CREATE TABLE ll_selfop_quantity_diagnostic_session ( diagnostic_session_id VARCHAR(128) NOT NULL COMMENT '固定有界数量诊断会话标识', - configuration_hash CHAR(64) NOT NULL COMMENT '冻结范围、UTC起止时间及限额的SHA256摘要', + configuration_hash CHAR(64) NOT NULL COMMENT '冻结授权范围的SHA256摘要', source_account VARCHAR(64) NOT NULL COMMENT '来源账号别名,不保存凭据', - collection_session_id VARCHAR(128) NOT NULL COMMENT '被观察的来源采集会话标识', - root_task_id VARCHAR(128) NOT NULL COMMENT '被观察的账号采集根任务标识', + collection_session_id VARCHAR(128) NULL COMMENT '被观察的来源采集会话标识', + root_task_id VARCHAR(128) NULL COMMENT '被观察的账号采集根任务标识', starts_at VARCHAR(40) NOT NULL COMMENT '诊断开始时刻,ISO8601 UTC字符串', - expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,距开始最多30分钟,ISO8601 UTC字符串', + expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,首个LIST观测后最多4小时且授权后最多10小时,ISO8601 UTC字符串', accepted_orders INT NOT NULL DEFAULT 0 COMMENT '已接收完整配对订单数,跨重启累计', - accepted_sku_rows INT NOT NULL DEFAULT 0 COMMENT '已接收归并SKU行数,跨重启累计', + accepted_sku_rows INT NOT NULL DEFAULT 0 COMMENT '两侧已保留SKU行数,跨重启累计', accepted_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '已持久化白名单规范JSON的UTF8字节数', stop_reason VARCHAR(64) NULL COMMENT '诊断终止原因,空值表示尚未终止', + active_slot INT NULL COMMENT '当前授权占位,终态释放;旧证据不自动授权', + state_json LONGTEXT NULL COMMENT '新授权的持久生命周期;NULL仅表示未参与此协议的历史证据', + UNIQUE KEY uq_qdiag_active (source_account, active_slot), + CONSTRAINT chk_qdiag_active CHECK (active_slot IS NULL OR active_slot = 1), PRIMARY KEY (diagnostic_session_id), CONSTRAINT chk_selfop_qdiag_limits CHECK (accepted_orders BETWEEN 0 AND 100 AND accepted_sku_rows BETWEEN 0 AND 2000 AND accepted_bytes BETWEEN 0 AND 2097152) @@ -26,7 +30,7 @@ CREATE TABLE ll_selfop_quantity_diagnostic_order ( order_no VARCHAR(128) NOT NULL COMMENT '来源出库单号', payload_hash CHAR(64) NOT NULL COMMENT '白名单规范载荷SHA256摘要,用于幂等校验', comparison VARCHAR(40) NOT NULL COMMENT 'SKU集合与三类数量比较结果', - sku_rows INT NOT NULL COMMENT '本单归并后的SKU并集行数', + sku_rows INT NOT NULL COMMENT '本单两侧较大的SKU行数;两侧累计额度由会话约束', payload_bytes INT NOT NULL COMMENT '本单白名单规范JSON的UTF8字节数', evidence_json JSON NOT NULL COMMENT '类型化列表与详情观测及SKU三量证据,不含原始响应或收件信息', received_at VARCHAR(40) NOT NULL COMMENT '接收时刻,ISO8601 UTC字符串', @@ -36,3 +40,14 @@ CREATE TABLE ll_selfop_quantity_diagnostic_order ( CONSTRAINT chk_selfop_qdiag_order_limits CHECK (sku_rows BETWEEN 1 AND 200 AND payload_bytes BETWEEN 1 AND 262144) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='独立配对订单数量诊断证据,不参与销量发布'; + +CREATE TABLE ll_selfop_quantity_diagnostic_pending ( + diagnostic_session_id VARCHAR(128) NOT NULL, + order_key_hash CHAR(64) NOT NULL, + warehouse_code VARCHAR(64) NOT NULL, + owner_code VARCHAR(128) NOT NULL, + order_no VARCHAR(128) NOT NULL, + evidence_json LONGTEXT NOT NULL, + PRIMARY KEY(diagnostic_session_id,order_key_hash), + FOREIGN KEY(diagnostic_session_id) REFERENCES ll_selfop_quantity_diagnostic_session(diagnostic_session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='首次LIST白名单证据;配对后同事务移至最终证据表'; diff --git a/lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml b/lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml index 63708501c..82391d797 100644 --- a/lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/selfop-quantity-diagnostics-internal-v1.yaml @@ -1,40 +1,168 @@ openapi: 3.0.3 info: - title: Internal bounded self-operated quantity diagnostics - version: 1.0.0 + title: Internal next-root bounded quantity diagnostics + version: 2.0.0 + description: Explicit arm only; no automatic rearm. Bind next normal automatic historical OUTBOUND_QUERY root + within 6h. Sampling expires 4h after first LIST observedAt, never later than armedAt+10h. Lastleg session lock + is receiver acceptance. Source root creation may predate arm. LOCATE, manual/validation roots, current excluded + root and different bound root are rejected/ignored. At most 100 distinct LIST candidates total, 2000 retained + SKU rows across both sides, 2MiB canonical evidence plus promotion overhead. Pending-to-final never refunds + quota. First unknown or SKU mismatch persists INCONCLUSIVE and stops; any known inequality among list/forecast/actual + persists QUANTITY_DIFFERENCE and stops. Original side timestamps/attempts are immutable; exact replay returns + stored receipt even after termination, changed same-side evidence is rejected. Root termination drains only + accepted candidates via an explicit Integration FIFO boundary without extending expiry. Fewer than 100 complete + pairs is ROOT_ENDED_INSUFFICIENT; unpaired/lost tail is INCOMPLETE. Producer restart retains persisted candidates + but marks delivery uncertain. Every request/response is stream-bounded to 262144 bytes. Never exposed through + Gateway, never creates supplier requests or changes business facts. paths: - /internal/lastleg/self-operated-analytics/quantity-diagnostics/orders: + /internal/lastleg/self-operated-analytics/quantity-diagnostics/arm: post: - operationId: receiveSelfopQuantityDiagnosticOrder - description: >- - Internal service-token only; never routed through Gateway. One complete paired order per request. - Quantities are evidence only. Session scope and UTC time interval are frozen by server configuration. - Maximum 100 orders, 2000 SKU rows and 2097152 canonical UTF8 bytes per session; - 200 SKU rows and 262144 wire/canonical bytes per request; at most 30 minutes. - First known forecast/actual difference closes capture regardless of comparison classification. - Same identity and canonical evidence is idempotent, including after closure; changed evidence is 409. + operationId: quantityDiagnosticArm security: - - internalServiceToken: [] + - internalServiceToken: [] requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PairedOrder' + $ref: '#/components/schemas/Arm' responses: '200': - description: Diagnostic receipt in the standard ApiResponse wrapper; code is string 200. + description: ApiResponse code 200. status data is null if no grant exists. content: application/json: schema: type: object + additionalProperties: false properties: - code: { type: string, example: '200' } - data: { $ref: '#/components/schemas/Receipt' } - '400': { description: 'Invalid JSON, unknown fields, invalid scope/evidence, duplicate SKU or request limit.' } - '401': { description: Invalid internal service token. } - '409': { description: Session configuration changed or order identity reused with different evidence. } - '503': { description: Internal service token unconfigured or diagnostic storage unavailable. } + code: + type: string + message: + type: string + nullable: true + data: + $ref: '#/components/schemas/View' + nullable: true + required: + - code + - data + '400': + description: Invalid scope, changed frozen evidence, invalid observation or disabled feature. + '401': + description: Missing or invalid internal service token. + '503': + description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected. + /internal/lastleg/self-operated-analytics/quantity-diagnostics/status: + post: + operationId: quantityDiagnosticStatus + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + responses: + '200': + description: ApiResponse code 200. status data is null if no grant exists. + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + code: + type: string + message: + type: string + nullable: true + data: + $ref: '#/components/schemas/View' + nullable: true + required: + - code + - data + '400': + description: Invalid scope, changed frozen evidence, invalid observation or disabled feature. + '401': + description: Missing or invalid internal service token. + '503': + description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected. + /internal/lastleg/self-operated-analytics/quantity-diagnostics/observe: + post: + operationId: quantityDiagnosticObserve + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + responses: + '200': + description: ApiResponse code 200. status data is null if no grant exists. + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + code: + type: string + message: + type: string + nullable: true + data: + $ref: '#/components/schemas/Receipt' + nullable: true + required: + - code + - data + '400': + description: Invalid scope, changed frozen evidence, invalid observation or disabled feature. + '401': + description: Missing or invalid internal service token. + '503': + description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected. + /internal/lastleg/self-operated-analytics/quantity-diagnostics/stop: + post: + operationId: quantityDiagnosticStop + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Stop' + responses: + '200': + description: ApiResponse code 200. status data is null if no grant exists. + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + code: + type: string + message: + type: string + nullable: true + data: + $ref: '#/components/schemas/View' + nullable: true + required: + - code + - data + '400': + description: Invalid scope, changed frozen evidence, invalid observation or disabled feature. + '401': + description: Missing or invalid internal service token. + '503': + description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected. components: securitySchemes: internalServiceToken: @@ -42,64 +170,365 @@ components: in: header name: X-Internal-Service-Token schemas: - PairedOrder: + Arm: type: object additionalProperties: false - required: [diagnosticSessionId, collectionSessionId, rootTaskId, sourceAccount, warehouseCode, ownerCode, orderNo, listObservation, detailObservation, skus] properties: - diagnosticSessionId: { type: string, maxLength: 128 } - collectionSessionId: { type: string, maxLength: 128 } - rootTaskId: { type: string, maxLength: 128 } - sourceAccount: { type: string, maxLength: 64 } - warehouseCode: { type: string, maxLength: 64 } - ownerCode: { type: string, maxLength: 128 } - orderNo: { type: string, maxLength: 128 } - listObservation: { $ref: '#/components/schemas/Observation' } - detailObservation: { $ref: '#/components/schemas/Observation' } - skus: + diagnosticSessionId: + type: string + maxLength: 128 + minLength: 1 + sourceAccount: + type: string + maxLength: 64 + minLength: 1 + warehouseCodes: type: array minItems: 1 - maxItems: 200 - description: Union of list/detail SKUs; duplicate source lines aggregated, unknown never zero-filled. - items: { $ref: '#/components/schemas/Sku' } + maxItems: 8 + uniqueItems: true + items: + type: string + maxLength: 64 + minLength: 1 + ownerCode: + type: string + maxLength: 128 + minLength: 1 + description: Explicit owner code, or * for all allowed owners. + excludedRootTaskId: + type: string + maxLength: 128 + minLength: 1 + nullable: true + description: Must match the actual started, unfinished automatic root at arm; null only when none exists. + required: + - diagnosticSessionId + - sourceAccount + - warehouseCodes + - ownerCode + Status: + type: object + additionalProperties: false + properties: + sourceAccount: + type: string + maxLength: 64 + minLength: 1 + producerId: + type: string + maxLength: 128 + minLength: 1 + attach: + type: boolean + description: True only on Integration process attachment; replacement marks delivery uncertain and fences + prior producer. + drained: + type: boolean + description: Only after observing DRAINING, closing intake and draining previously queued callbacks. + required: + - sourceAccount + - producerId + - attach + - drained + Stop: + type: object + additionalProperties: false + properties: + diagnosticSessionId: + type: string + maxLength: 128 + minLength: 1 + sourceAccount: + type: string + maxLength: 64 + minLength: 1 + producerId: + type: string + maxLength: 128 + minLength: 1 + reason: + type: string + enum: + - INCOMPLETE + - QUEUE_FULL + - OBSERVER_FAILED + - SHUTDOWN + required: + - diagnosticSessionId + - sourceAccount + - producerId + - reason Observation: type: object additionalProperties: false - required: [observedAt, attemptId, skuSetComplete] properties: - status: { type: string, nullable: true, maxLength: 32 } + status: + type: string + maxLength: 32 + minLength: 1 + nullable: true outboundTime: type: string + maxLength: 64 + minLength: 1 nullable: true - maxLength: 32 - description: Original Asia/Shanghai source text, null if absent; not reinterpreted as another timezone. - observedAt: { type: string, format: date-time } - attemptId: { type: string, maxLength: 128 } - skuSetComplete: { type: boolean } + observedAt: + type: string + format: date-time + attemptId: + type: string + maxLength: 128 + minLength: 1 + skuSetComplete: + type: boolean + required: + - status + - outboundTime + - observedAt + - attemptId + - skuSetComplete Sku: type: object additionalProperties: false - required: [sku, listPresent, detailPresent] properties: - sku: { type: string, maxLength: 128 } - listQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 } - forecastQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 } - actualQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 } - listPresent: { type: boolean } - detailPresent: { type: boolean } - Receipt: - type: object - required: [outcome, acceptedOrders, acceptedSkuRows, acceptedBytes] - properties: - outcome: { type: string, enum: [ACCEPTED, DUPLICATE, STOPPED] } - comparison: + sku: type: string + maxLength: 128 + minLength: 1 + listQty: + type: number nullable: true - enum: [SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE] + minimum: 0 + description: Up to 32 integer digits, at most 6 decimal places; unknown stays null. + forecastQty: + type: number + nullable: true + minimum: 0 + description: Up to 32 integer digits, at most 6 decimal places; unknown stays null. + actualQty: + type: number + nullable: true + minimum: 0 + description: Up to 32 integer digits, at most 6 decimal places; unknown stays null. + required: + - sku + - listQty + - forecastQty + - actualQty + Key: + type: object + additionalProperties: false + properties: + warehouseCode: + type: string + maxLength: 64 + minLength: 1 + ownerCode: + type: string + maxLength: 128 + minLength: 1 + orderNo: + type: string + maxLength: 128 + minLength: 1 + paired: + type: boolean + required: + - warehouseCode + - ownerCode + - orderNo + - paired + Event: + type: object + additionalProperties: false + properties: + diagnosticSessionId: + type: string + maxLength: 128 + minLength: 1 + sourceAccount: + type: string + maxLength: 64 + minLength: 1 + producerId: + type: string + maxLength: 128 + minLength: 1 + rootTaskId: + type: string + maxLength: 128 + minLength: 1 + collectionSessionId: + type: string + maxLength: 128 + minLength: 1 + operation: + type: string + enum: + - OUTBOUND_QUERY + - OUTBOUND_DETAIL + warehouseCode: + type: string + maxLength: 64 + minLength: 1 + ownerCode: + type: string + maxLength: 128 + minLength: 1 + orderNo: + type: string + maxLength: 128 + minLength: 1 + side: + type: string + enum: + - LIST + - DETAIL + observation: + $ref: '#/components/schemas/Observation' + skus: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/Sku' + description: Empty allowed only when skuSetComplete=false; persists inconclusive evidence. + required: + - diagnosticSessionId + - sourceAccount + - producerId + - rootTaskId + - collectionSessionId + - operation + - warehouseCode + - ownerCode + - orderNo + - side + - observation + - skus + View: + type: object + additionalProperties: false + properties: + diagnosticSessionId: + type: string + maxLength: 128 + minLength: 1 + sourceAccount: + type: string + maxLength: 64 + minLength: 1 + rootTaskId: + type: string + maxLength: 128 + minLength: 1 + nullable: true + collectionSessionId: + type: string + maxLength: 128 + minLength: 1 + nullable: true + state: + type: string + enum: + - ARMED + - BOUND + - DRAINING + - TERMINAL stopReason: type: string nullable: true - enum: [DISABLED, NOT_STARTED, EXPIRED, LIMIT_REACHED, FORECAST_ACTUAL_DIFFERENCE] - acceptedOrders: { type: integer } - acceptedSkuRows: { type: integer } - acceptedBytes: { type: integer, format: int64, description: Canonical whitelist JSON UTF8 bytes persisted in this session. } + startsAt: + type: string + format: date-time + nullable: true + expiresAt: + type: string + format: date-time + nullable: true + bindBefore: + type: string + format: date-time + nullable: true + warehouseCodes: + type: array + maxItems: 8 + items: + type: string + maxLength: 64 + minLength: 1 + ownerCode: + type: string + maxLength: 128 + minLength: 1 + excludedRootTaskId: + type: string + maxLength: 128 + minLength: 1 + nullable: true + candidates: + type: integer + minimum: 0 + maximum: 100 + pairedOrders: + type: integer + minimum: 0 + maximum: 100 + retainedRows: + type: integer + minimum: 0 + maximum: 2000 + retainedBytes: + type: integer + minimum: 0 + maximum: 2097152 + deliveryUncertain: + type: boolean + selected: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Key' + required: + - diagnosticSessionId + - sourceAccount + - rootTaskId + - collectionSessionId + - state + - stopReason + - startsAt + - expiresAt + - bindBefore + - warehouseCodes + - ownerCode + - excludedRootTaskId + - candidates + - pairedOrders + - retainedRows + - retainedBytes + - deliveryUncertain + - selected + Receipt: + type: object + additionalProperties: false + properties: + outcome: + type: string + enum: + - ACCEPTED + - IGNORED + - DUPLICATE + - STOPPED + comparison: + type: string + nullable: true + enum: + - ALL_EQUAL + - QUANTITY_DIFFERENCE + - INCONCLUSIVE + session: + $ref: '#/components/schemas/View' + required: + - outcome + - comparison + - session diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java index 3e36b1723..eb51575c1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java @@ -1,47 +1,27 @@ package com.baiyue.lastleg.selfoperated.analytics.diagnostic; - import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.fasterxml.jackson.databind.ObjectMapper; -import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; - +import org.springframework.mock.web.MockHttpServletRequest; class QuantityDiagnosticControllerTest { - @Test void httpStatusContractSurvivesSharedBusinessErrorAdvice() throws Exception { - var service = mock(QuantityDiagnosticService.class); - var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper()); - var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller) - .setControllerAdvice(new com.baiyue.common.web.GlobalExceptionHandler()).build(); - String path = "/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders"; - mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path) - .contentType("application/json").content("{}")) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized()); - mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path) - .header("X-Internal-Service-Token", "test-token").contentType("application/json").content("{\"address\":1}")) - .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest()); - } - @Test void rejectsInvalidTokenBeforeParsingOrStorage() { - var service = mock(QuantityDiagnosticService.class); - var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper()); - assertThatThrownBy(() -> controller.accept(null, new byte[0])).hasMessageContaining("401"); - assertThatThrownBy(() -> controller.accept("wrong", new byte[0])).hasMessageContaining("401"); - verifyNoInteractions(service); - } - @Test void missingTokenConfigurationFailsClosed() { - var service = mock(QuantityDiagnosticService.class); - var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard(""), service, new ObjectMapper()); - assertThatThrownBy(() -> controller.accept("any", new byte[0])).hasMessageContaining("503"); - verifyNoInteractions(service); - } - @Test void rejectsNonWhitelistedFieldsTrailingDocumentsAndOversizedPayload() { - var service = mock(QuantityDiagnosticService.class); - var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper()); - assertThatThrownBy(() -> controller.accept("test-token", "{\"address\":\"not allowed\"}".getBytes(StandardCharsets.UTF_8))) - .hasMessageContaining("DIAGNOSTIC_JSON_INVALID"); - assertThatThrownBy(() -> controller.accept("test-token", "{} {}".getBytes(StandardCharsets.UTF_8))) - .hasMessageContaining("DIAGNOSTIC_JSON_INVALID"); - assertThatThrownBy(() -> controller.accept("test-token", new byte[262145])).hasMessageContaining("DIAGNOSTIC_REQUEST_TOO_LARGE"); - verifyNoInteractions(service); - } + @Test void everyRouteRequiresInternalTokenBeforeParsing()throws Exception{ + var service=mock(QuantityDiagnosticService.class);var controller=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper()); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).setControllerAdvice(new com.baiyue.common.web.GlobalExceptionHandler()).build(); + for(String action:new String[]{"arm","status","observe","stop"})mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/internal/lastleg/self-operated-analytics/quantity-diagnostics/"+action).contentType("application/json").content("{}")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized()); + verifyNoInteractions(service); + } + @Test void strictWhitelistAndBoundedBody(){ + var service=mock(QuantityDiagnosticService.class);var c=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper()); + for(String body:new String[]{"{\"address\":1}","{} {}"}){var request=new MockHttpServletRequest();request.setContent(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("JSON_INVALID");} + var request=new MockHttpServletRequest();request.setContent(new byte[262145]);assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("REQUEST_TOO_LARGE");verifyNoInteractions(service); + } + @Test void streamIsLimitedEvenWithoutContentLength()throws Exception{ + var service=mock(QuantityDiagnosticService.class);var c=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper()); + var request=mock(jakarta.servlet.http.HttpServletRequest.class);when(request.getContentLengthLong()).thenReturn(-1L); + var count=new java.util.concurrent.atomic.AtomicInteger();var stream=new jakarta.servlet.ServletInputStream(){public int read(){count.incrementAndGet();return 32;}public boolean isFinished(){return false;}public boolean isReady(){return true;}public void setReadListener(jakarta.servlet.ReadListener listener){}}; + when(request.getInputStream()).thenReturn(stream);assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("REQUEST_TOO_LARGE");assertThat(count.get()).isEqualTo(262145);verifyNoInteractions(service); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java index d0ab0ab8b..debf7a3f9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java @@ -1,5 +1,4 @@ package com.baiyue.lastleg.selfoperated.analytics.diagnostic; - import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*; import static org.assertj.core.api.Assertions.*; import com.fasterxml.jackson.databind.ObjectMapper; @@ -12,158 +11,133 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.web.server.ResponseStatusException; - +import org.springframework.jdbc.datasource.*; class QuantityDiagnosticServiceTest { - private final Instant start = Instant.parse("2026-10-13T15:00:00Z"); - private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); - private JdbcTemplate jdbc; - private DataSourceTransactionManager manager; - private QuantityDiagnosticService service; - private QuantityDiagnosticProperties properties; - - @BeforeEach void prepare() throws Exception { - var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000", "sa", ""); - jdbc = new JdbcTemplate(ds); manager = new DataSourceTransactionManager(ds); - // Exercise the dedicated production DDL, omitting only MySQL table options/comments. - String ddl; - try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) { - ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8); - } - ddl = ddl.replaceAll("(?m)^--.*$", "").replaceAll("COMMENT\\s*=?\\s*'[^']*'", "") - .replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", ""); - for (String statement : ddl.split(";")) if (!statement.isBlank()) jdbc.execute(statement); - properties = config(start.plusSeconds(1800), true); - service = instance(properties, start.plusSeconds(10)); - } - private QuantityDiagnosticProperties config(Instant expires, boolean enabled) { - return new QuantityDiagnosticProperties(enabled, "diag-1", "collection-1", "root-1", "account-1", - List.of("warehouse-1"), start, expires); - } - private QuantityDiagnosticService instance(QuantityDiagnosticProperties p, Instant now) { - return new QuantityDiagnosticService(jdbc, manager, json, p, Clock.fixed(now, ZoneOffset.UTC)); - } - private Sku sku(String code, String list, String forecast, String actual) { - return new Sku(code, list == null ? null : new BigDecimal(list), forecast == null ? null : new BigDecimal(forecast), - actual == null ? null : new BigDecimal(actual), true, true); - } - private Order order(String no, Sku... skus) { - return new Order("diag-1", "collection-1", "root-1", "account-1", "warehouse-1", "owner-1", no, - new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(1), "list-1", true), - new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(2), "detail-1", true), List.of(skus)); - } - @Test void firstDifferencePersistsEvidenceAndStopsFurtherOrdersIncludingAfterRestart() { - var request = order("order-1", sku("sku-1", "10", "10", "8")); - var result = service.accept(request); - assertThat(result.outcome()).isEqualTo("ACCEPTED"); - assertThat(result.comparison()).isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE); - assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE"); - service = instance(properties, start.plusSeconds(20)); - assertThat(service.accept(request).outcome()).isEqualTo("DUPLICATE"); - assertThat(service.accept(order("order-2", sku("sku-1", "1", "1", "1"))).outcome()).isEqualTo("STOPPED"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1); - } - @Test void listMatchingActualStillRecordsForecastDifference() { - assertThat(service.accept(order("order-1", sku("sku", "8", "10", "8"))).comparison()) - .isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE); - } - @Test void idempotencyCanonicalizesDecimalsAndRowOrderButRejectsChangedEvidence() { - service.accept(order("order-1", sku("b", "1.0", "1", "1"), sku("a", "2", "2", "2"))); - var result = service.accept(order("order-1", sku("a", "2.00", "2", "2"), sku("b", "1", "1", "1"))); - assertThat(result.outcome()).isEqualTo("DUPLICATE"); - assertThat(result.acceptedOrders()).isEqualTo(1); - assertThatThrownBy(() -> service.accept(order("order-1", sku("a", "3", "3", "3")))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); - } - @Test void skuReplacementCannotBeHiddenByEqualTotalsAndDifferenceStillStops() { - var result = service.accept(order("order-1", - new Sku("old", BigDecimal.TEN, null, null, true, false), - new Sku("new", null, BigDecimal.TEN, BigDecimal.valueOf(8), false, true))); - assertThat(result.comparison()).isEqualTo(Comparison.SKU_SET_MISMATCH); - assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE"); - } - @Test void missingQuantityRemainsUnknown() { - var result = service.accept(order("order-1", sku("sku", null, "1", "1"))); - assertThat(result.comparison()).isEqualTo(Comparison.QUANTITY_UNKNOWN); - assertThat(result.stopReason()).isNull(); - } - @Test void duplicateSkusAndCrossGenerationAreRejectedWithoutWrites() { - assertThatThrownBy(() -> service.accept(order("order-1", sku("sku", "1", "1", "1"), sku("sku", "1", "1", "1")))) - .hasMessageContaining("DIAGNOSTIC_DUPLICATE_SKU"); - Order r = order("order-1", sku("sku", "1", "1", "1")); - assertThatThrownBy(() -> service.accept(new Order(r.diagnosticSessionId(), "other-generation", r.rootTaskId(), r.sourceAccount(), - r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), r.detailObservation(), r.skus()))) - .hasMessageContaining("DIAGNOSTIC_SCOPE_MISMATCH"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero(); - } - @Test void expiredAndReconfiguredSessionsCannotResetCounters() { - var r = order("order-1", sku("sku", "1", "1", "1")); service.accept(r); - var expired = instance(properties, start.plusSeconds(1800)); - assertThat(expired.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("EXPIRED"); - assertThat(expired.accept(r).outcome()).isEqualTo("DUPLICATE"); - assertThatThrownBy(() -> instance(config(start.plusSeconds(1799), true), start.plusSeconds(20)).accept(r)) - .hasMessageContaining("DIAGNOSTIC_SESSION_CONFIGURATION_CHANGED"); - } - @Test void disabledRequiresNoTablesOrValidSession() { - jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_order"); - jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_session"); - assertThat(instance(config(start.plusSeconds(1800), false), start).accept(null).stopReason()).isEqualTo("DISABLED"); - } - @Test void concurrentRequestsCannotExceedHundredOrders() throws Exception { - var pool = Executors.newFixedThreadPool(4); - try { - List> jobs = new ArrayList<>(); - for (int i = 0; i < 104; i++) { final int n = i; jobs.add(() -> service.accept(order("order-" + n, sku("sku", "1", "1", "1")))); } - var results = pool.invokeAll(jobs); - int accepted = 0; - for (var result : results) if (result.get().outcome().equals("ACCEPTED")) accepted++; - assertThat(accepted).isEqualTo(100); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(100); - assertThat(instance(properties, start.plusSeconds(20)).accept(order("after-restart", sku("sku", "1", "1", "1"))).stopReason()) - .isEqualTo("LIMIT_REACHED"); - } finally { pool.shutdownNow(); } - } - @Test void rowLimitAndFailedOrderInsertDoNotPartiallyAdvanceCounters() { - service.accept(order("order-1", sku("sku", "1", "1", "1"))); - jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=2000"); - assertThat(service.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("LIMIT_REACHED"); - assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isEqualTo(1); - } - @Test void byteLimitCannotBeResetByRestartOrPartiallySaveOrder() { - service.accept(order("order-1", sku("sku", "1", "1", "1"))); - jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_bytes=2097150"); - var receipt = instance(properties, start.plusSeconds(20)).accept(order("order-2", sku("sku", "1", "1", "1"))); - assertThat(receipt.stopReason()).isEqualTo("LIMIT_REACHED"); - assertThat(receipt.acceptedBytes()).isEqualTo(2097150); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1); - } - @Test void dedicatedDdlHasChineseCommentsOnEveryTableAndOrdinaryColumn() throws Exception { - String ddl; - try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) { - ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8); - } - var fields = java.util.regex.Pattern.compile("(?m)^ [a-z][a-z_]+ (?:VARCHAR|CHAR|INT|BIGINT|JSON)\\b[^\\n]*").matcher(ddl); - int count = 0; - while (fields.find()) { - assertThat(fields.group()).matches("(?s).*COMMENT '[^']*[\\u4e00-\\u9fff][^']*'.*"); count++; - } - assertThat(count).isEqualTo(23); - assertThat(java.util.regex.Pattern.compile("COMMENT='[^']*[\\u4e00-\\u9fff][^']*';").matcher(ddl).results().count()).isEqualTo(2); - } - @Test void storageFailureRollsBackSessionCounterAndSessionCreation() { - jdbc.execute("ALTER TABLE ll_selfop_quantity_diagnostic_order ADD CONSTRAINT reject_test_order CHECK(order_no <> 'bad-order')"); - assertThatThrownBy(() -> service.accept(order("bad-order", sku("sku", "1", "1", "1")))) - .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero(); - } - @Test void cancellationAndMissingOutboundTimeRemainEvidenceOnly() { - var r = order("cancelled", sku("sku", "1", "1", "1")); - var cancelled = new Observation("cancelled", null, start.plusSeconds(2), "detail-cancel", true); - var result = service.accept(new Order(r.diagnosticSessionId(), r.collectionSessionId(), r.rootTaskId(), r.sourceAccount(), - r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), cancelled, r.skus())); - assertThat(result.comparison()).isEqualTo(Comparison.ALL_EQUAL); - assertThat(result.acceptedOrders()).isEqualTo(1); - } + final Instant start=Instant.parse("2026-10-14T00:00:00Z"); + final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + JdbcTemplate jdbc;DataSourceTransactionManager manager;QuantityDiagnosticService service;MutableClock clock; + final QuantityDiagnosticProperties config=new QuantityDiagnosticProperties(true,"account",List.of("W1","W2")); + @BeforeEach void setup()throws Exception{ + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000","sa","");jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds);clock=new MutableClock(start); + String ddl;try(var in=new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()){ddl=new String(in.readAllBytes(),StandardCharsets.UTF_8);} + ddl=ddl.replaceAll("(?m)^--.*$","").replaceAll("COMMENT\\s*=?\\s*'[^']*'", "").replace("evidence_json JSON", "evidence_json LONGTEXT").replace("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin","");for(String sql:ddl.split(";"))if(!sql.isBlank())jdbc.execute(sql); + jdbc.execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,source_account VARCHAR(64),execution_purpose VARCHAR(32),upstream_task_id BIGINT,validation_root_task_id BIGINT,requested_by VARCHAR(64),scope_hash VARCHAR(64),request_key VARCHAR(128),task_role VARCHAR(32),task_type VARCHAR(32),discovery_scope_id BIGINT,query_from_utc TIMESTAMP,query_until_utc TIMESTAMP,query_scope_json VARCHAR(2000),status VARCHAR(32),attempt_count INT DEFAULT 0)"); + for(long id=1;id<=3;id++)jdbc.update("INSERT INTO ll_selfop_sync_task VALUES(?,?,?,NULL,NULL,?,?,?,?,?,?,?, ?,?,?,0)",id,"account","BUSINESS","SELFOP_CONTINUOUS_PLANNER","a".repeat(64),"continuous:outbound:2026-10-10:"+"a".repeat(12),"ACCOUNT_DISCOVERY","BACKFILL",1L,LocalDateTime.parse("2026-10-09T16:00:00"),LocalDateTime.parse("2026-10-10T16:00:00"),"{\"startTime\":\"2026-10-10T00:00\",\"endTime\":\"2026-10-11T00:00\"}",id==1?"RUNNING":"READY"); + service=instance();service.arm(arm("diag"));service.status(new Status("account","producer",true,false));clock.now=start.plusSeconds(10); + } + QuantityDiagnosticService instance(){return new QuantityDiagnosticService(jdbc,manager,json,config,clock);} + Arm arm(String id){return new Arm(id,"account",List.of("W1","W2"),"*","1");} + Event event(String root,String order,String side,BigDecimal list,BigDecimal forecast,BigDecimal actual){return new Event("diag","account","producer",root,"selfop-"+root,"LIST".equals(side)?"OUTBOUND_QUERY":"OUTBOUND_DETAIL","W1","owner",order,side,new Observation("100",null,clock.instant(),"attempt",true),List.of(new Sku("SKU",list,forecast,actual)));} + Event list(String order){return event("2",order,"LIST",BigDecimal.TEN,null,null);} + Event detail(String order){return event("2",order,"DETAIL",null,BigDecimal.TEN,BigDecimal.TEN);} + View status(){return service.status(new Status("account","producer",false,false));} + @Test void explicitArmIsIdempotentAndBindsOnlyNextEligibleList(){ + assertThat(service.arm(arm("diag")).startsAt()).isEqualTo(start); + assertThat(service.observe(detail("early")).outcome()).isEqualTo("IGNORED"); + assertThat(service.observe(event("1","old","LIST",BigDecimal.ONE,null,null)).outcome()).isEqualTo("IGNORED"); + Event locate=list("locate");assertThatThrownBy(()->service.observe(new Event(locate.diagnosticSessionId(),locate.sourceAccount(),locate.producerId(),locate.rootTaskId(),locate.collectionSessionId(),"OUTBOUND_LOCATE",locate.warehouseCode(),locate.ownerCode(),locate.orderNo(),locate.side(),locate.observation(),locate.skus()))).hasMessageContaining("OPERATION"); + View bound=service.observe(list("a")).session();assertThat(bound.rootTaskId()).isEqualTo("2");assertThat(bound.expiresAt()).isEqualTo(clock.instant().plusSeconds(14400)); + assertThatThrownBy(()->service.observe(event("3","other","LIST",BigDecimal.ONE,null,null))).hasMessageContaining("ROOT_FROZEN"); + assertThatThrownBy(()->service.arm(arm("second"))).hasMessageContaining("ALREADY_ARMED"); + } + @Test void durablePendingSurvivesReceiverRestartAndPreservesOriginalAttempt(){ + Event original=list("a");service.observe(original);service=instance();clock.now=start.plusSeconds(5000); + assertThat(service.observe(original).outcome()).isEqualTo("DUPLICATE"); + var result=service.observe(detail("a"));assertThat(result.comparison()).isEqualTo("ALL_EQUAL");assertThat(result.session().candidates()).isEqualTo(1); + String payload=jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_order",String.class);assertThat(payload).contains("attempt").contains("00:00:10Z"); + } + @Test void anyKnownInequalityStopsIncludingListDifferenceAndTerminalReplay(){ + Event l=list("a");service.observe(l);Event d=event("2","a","DETAIL",null,BigDecimal.ONE,BigDecimal.ONE); + var result=service.observe(d);assertThat(result.comparison()).isEqualTo("QUANTITY_DIFFERENCE");assertThat(result.session().state()).isEqualTo("TERMINAL"); + assertThat(instance().observe(d).outcome()).isEqualTo("DUPLICATE");assertThat(service.observe(list("b")).outcome()).isEqualTo("STOPPED"); + } + @Test void unknownListStopsAndRetainsEvidence(){ + var receipt=service.observe(event("2","a","LIST",null,null,null));assertThat(receipt.comparison()).isEqualTo("INCONCLUSIVE");assertThat(receipt.session().stopReason()).isEqualTo("INCONCLUSIVE"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isEqualTo(1); + } + @Test void missingSkuAndUnknownDetailStopAtomically(){ + service.observe(list("a"));Event d=detail("a");var mismatch=new Event(d.diagnosticSessionId(),d.sourceAccount(),d.producerId(),d.rootTaskId(),d.collectionSessionId(),d.operation(),d.warehouseCode(),d.ownerCode(),d.orderNo(),d.side(),d.observation(),List.of(new Sku("OTHER",null,null,BigDecimal.TEN))); + assertThat(service.observe(mismatch).comparison()).isEqualTo("INCONCLUSIVE");assertThat(status().state()).isEqualTo("TERMINAL");assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order",Integer.class)).isEqualTo(1); + } + @Test void lateCallbackCannotBindEvenWhenObservationWasBeforeDeadline(){ + Event e=list("a");clock.now=start.plusSeconds(21600);assertThat(service.observe(e).session().stopReason()).isEqualTo("BIND_TIMEOUT");assertThat(status().rootTaskId()).isNull(); + } + @Test void latestPossibleBindStillCannotExtendAbsoluteTenHours(){ + clock.now=start.plusSeconds(21599);assertThat(service.observe(list("a")).session().expiresAt()).isEqualTo(start.plusSeconds(35999)); + clock.now=start.plusSeconds(36000);assertThat(service.observe(detail("a")).session().stopReason()).isEqualTo("INCOMPLETE"); + } + @Test void rootEndDrainsOnlyAcceptedCandidatesAndNeverExtendsDeadline(){ + service.observe(list("a"));Instant expires=status().expiresAt();jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=2"); + assertThat(status().state()).isEqualTo("DRAINING");assertThat(service.observe(list("new")).outcome()).isEqualTo("IGNORED"); + assertThat(service.observe(detail("a")).comparison()).isEqualTo("ALL_EQUAL"); + View done=service.status(new Status("account","producer",false,true));assertThat(done.stopReason()).isEqualTo("ROOT_ENDED_INSUFFICIENT");assertThat(done.expiresAt()).isEqualTo(expires); + } + @Test void producerRestartMarksLostTailUncertainButRestoresPending(){ + service.observe(list("a"));View recovered=service.status(new Status("account","new-producer",true,false));assertThat(recovered.deliveryUncertain()).isTrue();assertThat(recovered.selected()).hasSize(1); + Event d=detail("a");var resumed=new Event(d.diagnosticSessionId(),d.sourceAccount(),"new-producer",d.rootTaskId(),d.collectionSessionId(),d.operation(),d.warehouseCode(),d.ownerCode(),d.orderNo(),d.side(),d.observation(),d.skus());assertThat(service.observe(resumed).comparison()).isEqualTo("ALL_EQUAL"); + jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=2");assertThat(service.status(new Status("account","new-producer",false,true)).stopReason()).isEqualTo("INCOMPLETE"); + } + @Test void candidatesAreCumulativeAndNeverReplaced(){ + for(int i=0;i<100;i++)service.observe(list("o"+i));assertThat(service.observe(list("extra")).outcome()).isEqualTo("IGNORED"); + for(int i=0;i<100;i++)service.observe(detail("o"+i));View done=status();assertThat(done.candidates()).isEqualTo(100);assertThat(done.pairedOrders()).isEqualTo(100);assertThat(done.retainedRows()).isEqualTo(200);assertThat(done.stopReason()).isEqualTo("COMPLETED"); + } + @Test void changedAttemptCannotOverwriteFirstSide(){ + Event a=list("a");service.observe(a);clock.now=start.plusSeconds(20);assertThatThrownBy(()->service.observe(list("a"))).hasMessageContaining("OBSERVATION_CONFLICT");assertThat(status().candidates()).isEqualTo(1); + } + @Test void concurrentReceiversSerializeSameCandidate()throws Exception{ + var other=instance();Event e=list("a");var executor=Executors.newFixedThreadPool(2);try{var one=executor.submit(()->service.observe(e));var two=executor.submit(()->other.observe(e));assertThat(List.of(one.get().outcome(),two.get().outcome())).containsExactlyInAnyOrder("ACCEPTED","DUPLICATE");}finally{executor.shutdownNow();}assertThat(status().candidates()).isEqualTo(1); + } + @Test void businessRootPolicyIsAuthoritative(){ + jdbc.update("UPDATE ll_selfop_sync_task SET requested_by='manual' WHERE id=2");assertThatThrownBy(()->service.observe(list("a"))).hasMessageContaining("ROOT_REJECTED");assertThat(status().rootTaskId()).isNull(); + } + @Test void delayedFirstObservationDoesNotExtendSamplingWindow(){ + Event first=list("a");clock.now=start.plusSeconds(5000);View bound=service.observe(first).session();assertThat(bound.startsAt()).isEqualTo(first.observation().observedAt());assertThat(bound.expiresAt()).isEqualTo(start.plusSeconds(14410)); + } + @Test void endedOrChangedRootCannotBePresentedAsCurrent(){ + service.stop(new Stop("diag","account","producer","INCOMPLETE"));jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=1"); + assertThatThrownBy(()->service.arm(arm("next"))).hasMessageContaining("CURRENT_ROOT_CHANGED"); + assertThat(service.arm(new Arm("next","account",List.of("W1"),"*",null)).excludedRootTaskId()).isNull(); + } + @Test void ambiguousCurrentRootsRefuseArm(){ + service.stop(new Stop("diag","account","producer","INCOMPLETE"));jdbc.update("UPDATE ll_selfop_sync_task SET status='RUNNING' WHERE id=2"); + assertThatThrownBy(()->service.arm(arm("next"))).hasMessageContaining("CURRENT_ROOT_AMBIGUOUS"); + } + @Test void diagnosticDatasourceInitializationFailureDoesNotBreakServiceConstruction(){ + var unavailable=new QuantityDiagnosticService(new DriverManagerDataSource(),json,config); + assertThatThrownBy(()->unavailable.status(new Status("account","p",true,false))).hasMessageContaining("DIAGNOSTIC_DATASOURCE_UNAVAILABLE");unavailable.close(); + } + @Test void fourHourExpiredDelayedFirstListIsNotAccepted(){ + Event first=list("a");clock.now=start.plusSeconds(15000);var r=service.observe(first);assertThat(r.outcome()).isEqualTo("STOPPED");assertThat(r.session().candidates()).isZero(); + } + @Test void rowAndByteCapsNeverRefundOrAcceptOverLimit()throws Exception{ + service.observe(list("a"));jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=2000"); + assertThat(service.observe(detail("a")).session().stopReason()).isEqualTo("INCOMPLETE");assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isEqualTo(1); + jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=1,accepted_bytes=2097152,stop_reason=NULL,active_slot=1"); + assertThat(service.observe(detail("a")).outcome()).isEqualTo("STOPPED");assertThat(status().retainedBytes()).isEqualTo(2097152); + } + @Test void concurrentLastCandidateCannotExceedOneHundred()throws Exception{ + for(int i=0;i<99;i++)service.observe(list("o"+i));var other=instance();var pool=Executors.newFixedThreadPool(2); + try{var a=pool.submit(()->service.observe(list("last-a")));var b=pool.submit(()->other.observe(list("last-b")));assertThat(List.of(a.get().outcome(),b.get().outcome())).containsExactlyInAnyOrder("ACCEPTED","IGNORED");}finally{pool.shutdownNow();}assertThat(status().candidates()).isEqualTo(100); + } + @Test void admissionRejectsBeforeSecondConnectionAcquisition()throws Exception{ + var tm=org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);var entered=new CountDownLatch(1);var release=new CountDownLatch(1); + org.mockito.Mockito.when(tm.getTransaction(org.mockito.ArgumentMatchers.any())).thenAnswer(call->{entered.countDown();release.await(2,TimeUnit.SECONDS);return new org.springframework.transaction.support.SimpleTransactionStatus();}); + var bounded=new QuantityDiagnosticService(jdbc,tm,json,config,clock);var pool=Executors.newSingleThreadExecutor(); + try{var first=pool.submit(()->bounded.status(new Status("account","producer",false,false)));assertThat(entered.await(1,TimeUnit.SECONDS)).isTrue();assertThatThrownBy(()->bounded.status(new Status("account","producer",false,false))).hasMessageContaining("DIAGNOSTIC_BUSY");release.countDown();first.get();org.mockito.Mockito.verify(tm,org.mockito.Mockito.times(1)).getTransaction(org.mockito.ArgumentMatchers.any());}finally{release.countDown();pool.shutdownNow();} + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"10","8"}) + void forecastTenActualEightStopsRegardlessOfListMeaning(String listQuantity){ + service.observe(event("2","a","LIST",new BigDecimal(listQuantity),null,null));assertThat(service.observe(event("2","a","DETAIL",null,BigDecimal.TEN,new BigDecimal("8"))).comparison()).isEqualTo("QUANTITY_DIFFERENCE"); + } + @Test void historicalEvidenceRemainsUntouchedAndCannotRearmItsIdentifier(){ + jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_session(diagnostic_session_id,configuration_hash,source_account,collection_session_id,root_task_id,starts_at,expires_at,accepted_orders,accepted_sku_rows,accepted_bytes,stop_reason) VALUES('old',?,'account','old-session','1',?,?,24,25,1234,'EXPIRED')","a".repeat(64),start.toString(),start.toString()); + assertThatThrownBy(()->service.arm(arm("old"))).hasMessageContaining("HISTORICAL_ID_READ_ONLY");assertThat(status().diagnosticSessionId()).isEqualTo("diag");assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id='old'",Integer.class)).isEqualTo(24); + } + @Test void existingStopReasonAndCountersAreTheOnlyPersistedFacts(){ + service.observe(list("a"));service.observe(event("2","a","DETAIL",null,BigDecimal.TEN,BigDecimal.ONE)); + assertThat(jdbc.queryForObject("SELECT stop_reason FROM ll_selfop_quantity_diagnostic_session",String.class)).isEqualTo("QUANTITY_DIFFERENCE");assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session",Integer.class)).isEqualTo(1); + String metadata=jdbc.queryForObject("SELECT state_json FROM ll_selfop_quantity_diagnostic_session",String.class);assertThat(metadata).doesNotContain("\"reason\"","\"paired\"","\"bytes\"","\"expiresAt\"");assertThat(instance().status(new Status("account","producer",false,false)).stopReason()).isEqualTo("QUANTITY_DIFFERENCE"); + } + static class MutableClock extends Clock {Instant now;MutableClock(Instant now){this.now=now;}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;}public Instant instant(){return now;}} } From 4ae158c93e2aa5d77761307d8d39c25077ab9444 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 05:05:23 +0800 Subject: [PATCH 64/80] fix(selfop): preserve exact diagnostic decimals in JSON evidence --- .../diagnostic/QuantityDiagnosticService.java | 36 ++++++++++++- .../QuantityDiagnosticControllerTest.java | 7 +++ .../QuantityDiagnosticServiceTest.java | 50 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java index 995d78962..ceeb17c26 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java @@ -5,6 +5,14 @@ import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagn import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; import com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import java.io.IOException; +import java.math.BigDecimal; import com.zaxxer.hikari.HikariDataSource; import jakarta.annotation.PreDestroy; import javax.sql.DataSource; @@ -37,7 +45,7 @@ public class QuantityDiagnosticService { @Autowired public QuantityDiagnosticService(DataSource source, ObjectMapper json, QuantityDiagnosticProperties config) { - this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.json=storageMapper(json); this.config=config; this.clock=Clock.systemUTC(); // Separate, bounded connection acquisition. Diagnostics cannot exhaust the collection pool. if(config.enabled()) { @@ -55,7 +63,7 @@ public class QuantityDiagnosticService { } QuantityDiagnosticService(JdbcTemplate jdbc,PlatformTransactionManager manager,ObjectMapper json, QuantityDiagnosticProperties config,Clock clock) { - this.jdbc=jdbc;this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.jdbc=jdbc;this.json=storageMapper(json); this.config=config;this.clock=clock;tx=new TransactionTemplate(manager);tx.setTimeout(3); } @PreDestroy public void close(){if(pool!=null)pool.close();} @@ -204,6 +212,30 @@ public class QuantityDiagnosticService { } private String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw invalid("DIAGNOSTIC_ENCODING_FAILED");}} private T decode(String value,Class type){try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("DIAGNOSTIC_STORAGE_INVALID");}} + /** Storage-only codec. The controller and Integration wire codec continue using numeric BigDecimal. */ + private static ObjectMapper storageMapper(ObjectMapper source) { + var module=new SimpleModule("quantity-diagnostic-storage-decimals"); + module.addSerializer(BigDecimal.class,new StdSerializer(BigDecimal.class) { + @Override public void serialize(BigDecimal value,JsonGenerator generator, + com.fasterxml.jackson.databind.SerializerProvider provider)throws IOException { + generator.writeString(value.stripTrailingZeros().toPlainString()); + } + }); + module.addDeserializer(BigDecimal.class,new StdDeserializer(BigDecimal.class) { + @Override public BigDecimal deserialize(JsonParser parser, + com.fasterxml.jackson.databind.DeserializationContext context)throws IOException { + // New grants never read historical numeric evidence; no numeric fallback/coercion. + if(!parser.hasToken(JsonToken.VALUE_STRING))throw new IOException("DIAGNOSTIC_STORAGE_DECIMAL_REQUIRED"); + String text=parser.getText(); + if(!text.matches("(?:0|[1-9][0-9]{0,31})(?:\\.[0-9]{1,6})?"))throw new IOException("DIAGNOSTIC_STORAGE_DECIMAL_INVALID"); + BigDecimal value=new BigDecimal(text); + if(!text.equals(value.stripTrailingZeros().toPlainString()))throw new IOException("DIAGNOSTIC_STORAGE_DECIMAL_NOT_CANONICAL"); + return value; + } + }); + return source.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .registerModule(module); + } private static int size(String s){return s.getBytes(StandardCharsets.UTF_8).length;} private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} public record Pair(Event list,Event detail){} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java index eb51575c1..373d3e2d5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java @@ -24,4 +24,11 @@ class QuantityDiagnosticControllerTest { var count=new java.util.concurrent.atomic.AtomicInteger();var stream=new jakarta.servlet.ServletInputStream(){public int read(){count.incrementAndGet();return 32;}public boolean isFinished(){return false;}public boolean isReady(){return true;}public void setReadListener(jakarta.servlet.ReadListener listener){}}; when(request.getInputStream()).thenReturn(stream);assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("REQUEST_TOO_LARGE");assertThat(count.get()).isEqualTo(262145);verifyNoInteractions(service); } + @Test void normalCallbackNumbersStillDeserializeToExactBigDecimal(){ + var service=mock(QuantityDiagnosticService.class);var controller=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper().findAndRegisterModules()); + var request=new MockHttpServletRequest();request.setContent("{\"side\":\"LIST\",\"skus\":[{\"sku\":\"BIG\",\"listQty\":100000000000000000000.000001},{\"sku\":\"MICRO\",\"listQty\":0.000001}]}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + controller.observe("test-token",request);var argument=org.mockito.ArgumentCaptor.forClass(QuantityDiagnosticContract.Event.class);verify(service).observe(argument.capture()); + assertThat(argument.getValue().skus().get(0).listQty()).isEqualByComparingTo("100000000000000000000.000001");assertThat(argument.getValue().skus().get(1).listQty()).isEqualByComparingTo("0.000001"); + } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java index debf7a3f9..7952612bf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java @@ -139,5 +139,55 @@ class QuantityDiagnosticServiceTest { assertThat(jdbc.queryForObject("SELECT stop_reason FROM ll_selfop_quantity_diagnostic_session",String.class)).isEqualTo("QUANTITY_DIFFERENCE");assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session",Integer.class)).isEqualTo(1); String metadata=jdbc.queryForObject("SELECT state_json FROM ll_selfop_quantity_diagnostic_session",String.class);assertThat(metadata).doesNotContain("\"reason\"","\"paired\"","\"bytes\"","\"expiresAt\"");assertThat(instance().status(new Status("account","producer",false,false)).stopReason()).isEqualTo("QUANTITY_DIFFERENCE"); } + @Test void highPrecisionStorageUsesStringsAndSurvivesJsonNormalizationAndTerminalReplay()throws Exception{ + String large="100000000000000000000.000001",micro="0.000001",maximum="99999999999999999999999999999999.999999"; + Event base=list("precision"); + Event l=withSkus(base,List.of(new Sku("BIG",new BigDecimal(large),null,null),new Sku("MICRO",new BigDecimal(micro),null,null),new Sku("MAX",new BigDecimal(maximum),null,null))); + Event d=withSkus(detail("precision"),List.of(new Sku("BIG",null,new BigDecimal(large),new BigDecimal(large)),new Sku("MICRO",null,new BigDecimal(micro),new BigDecimal("0.000002")),new Sku("MAX",null,new BigDecimal(maximum),new BigDecimal(maximum)))); + service.observe(l); + var pending=json.readTree(jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_pending",String.class)); + for(var sku:pending.path("skus")){assertThat(sku.path("listQty").isTextual()).isTrue();assertThat(sku.path("forecastQty").isNull()).isTrue();assertThat(sku.path("actualQty").isNull()).isTrue();} + assertThat(service.observe(d).session().stopReason()).isEqualTo("QUANTITY_DIFFERENCE"); + String evidence=jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_order",String.class); + String storedHash=jdbc.queryForObject("SELECT payload_hash FROM ll_selfop_quantity_diagnostic_order",String.class); + var tree=json.readTree(evidence); + for(String side:List.of("list","detail"))for(var sku:tree.path(side).path("skus"))for(String field:List.of("listQty","forecastQty","actualQty"))assertThat(sku.path(field).isNull()||sku.path(field).isTextual()).isTrue(); + assertThat(tree.path("list").path("skus").get(0).path("listQty").textValue()).isEqualTo(large); + // Reproduces the precision hazard of untyped JSON numeric normalization, without a MySQL service. + Map unsafe=json.readValue("{\"qty\":"+large+"}",Map.class); + assertThat(new BigDecimal(unsafe.get("qty").toString())).isNotEqualByComparingTo(large); + String reordered=json.copy().enable(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(json.readValue(evidence,Map.class)); + assertThat(reordered).isNotEqualTo(evidence); + // Exercise an actual H2 JSON column too, rather than relying only on the portable LONGTEXT fixture. + jdbc.execute("CREATE TABLE qdiag_json_roundtrip(evidence JSON)");jdbc.update("INSERT INTO qdiag_json_roundtrip VALUES (? FORMAT JSON)",reordered); + String persisted=jdbc.queryForObject("SELECT evidence FROM qdiag_json_roundtrip",String.class); + jdbc.update("UPDATE ll_selfop_quantity_diagnostic_order SET evidence_json=?",persisted); + assertThat(instance().observe(l).outcome()).isEqualTo("DUPLICATE");assertThat(instance().observe(d).outcome()).isEqualTo("DUPLICATE"); + assertThat(jdbc.queryForObject("SELECT payload_hash FROM ll_selfop_quantity_diagnostic_order",String.class)).isEqualTo(storedHash); + assertThat(storedHash).isEqualTo(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(evidence.getBytes(StandardCharsets.UTF_8)))); + assertThat(status().pairedOrders()).isEqualTo(1); + // Private storage mapper must not mutate the normal numeric wire serializer. + assertThat(json.readTree(json.writeValueAsString(l)).path("skus").get(0).path("listQty").isNumber()).isTrue(); + } + @Test void trailingZerosAndNegativeZeroShareCanonicalStorageAndReplay()throws Exception{ + Event l=event("2","a","LIST",new BigDecimal("12.340000"),null,null);service.observe(l); + assertThat(service.observe(event("2","a","LIST",new BigDecimal("12.34"),null,null)).outcome()).isEqualTo("DUPLICATE"); + Event d=event("2","a","DETAIL",null,new BigDecimal("12.3400"),new BigDecimal("-0.000000"));service.observe(d); + var stored=json.readTree(jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_order",String.class)); + assertThat(stored.path("list").path("skus").get(0).path("listQty").textValue()).isEqualTo("12.34");assertThat(stored.path("detail").path("skus").get(0).path("actualQty").textValue()).isEqualTo("0"); + assertThat(instance().observe(event("2","a","DETAIL",null,new BigDecimal("12.34"),BigDecimal.ZERO)).outcome()).isEqualTo("DUPLICATE"); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"-1","-0.000001"}) + void negativeQuantitiesAreRejectedBeforeEvidencePersistence(String quantity){ + assertThatThrownBy(()->service.observe(event("2","a","LIST",new BigDecimal(quantity),null,null))).hasMessageContaining("QUANTITY_INVALID"); + assertThat(status().candidates()).isZero();assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isZero(); + } + @Test void newGrantStorageRejectsNumericEvidenceInsteadOfAddingLegacyCoercion(){ + service.observe(list("a"));String stored=jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_pending",String.class); + jdbc.update("UPDATE ll_selfop_quantity_diagnostic_pending SET evidence_json=?",stored.replace("\"listQty\":\"10\"","\"listQty\":10")); + assertThatThrownBy(()->instance().observe(detail("a"))).hasMessageContaining("DIAGNOSTIC_STORAGE_INVALID"); + } + private Event withSkus(Event e,List skus){return new Event(e.diagnosticSessionId(),e.sourceAccount(),e.producerId(),e.rootTaskId(),e.collectionSessionId(),e.operation(),e.warehouseCode(),e.ownerCode(),e.orderNo(),e.side(),e.observation(),skus);} static class MutableClock extends Clock {Instant now;MutableClock(Instant now){this.now=now;}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;}public Instant instant(){return now;}} } From c640fa2d0d4d9a926f272754679101c23645d6c4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 08:15:16 +0800 Subject: [PATCH 65/80] feat(selfop): prefer list SKU quantities with targeted detail fallback --- .../dto/lingxing/LingxingWmsDtos.java | 3 +- .../provider/lingxing/LingxingWmsClient.java | 13 ++- .../resources/openapi/lingxing-wms-v1.yaml | 10 +++ .../LingxingInternalControllerTest.java | 2 +- .../lingxing/LingxingWmsClientTest.java | 11 +++ .../client/LingxingAnalyticsClient.java | 12 ++- .../client/RestLingxingAnalyticsClient.java | 13 ++- .../model/SelfopRoutingManifest.java | 1 + .../analytics/sync/SelfopBudget.java | 3 +- .../sync/SelfopDiscoveryService.java | 75 ++++++++++++----- .../sync/SelfopValidationRunner.java | 6 +- .../RestLingxingAnalyticsClientTest.java | 15 ++++ .../sync/SelfopDiscoveryServiceTest.java | 83 ++++++++++++++++++- .../sync/SelfopValidationRunnerTest.java | 4 +- 14 files changed, 220 insertions(+), 31 deletions(-) diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java index 52257c682..1e43243bb 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -45,8 +45,9 @@ public final class LingxingWmsDtos { } // Preserve source strings; the collector interprets confirmed Asia/Shanghai source time. + public record ListSku(String productSku, String productName, BigDecimal qty) { } public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode, - Integer status, String createTime, String outboundTime) { } + Integer status, String createTime, String outboundTime, List productList) { } public record PageResult(List records, Long total, int current, int size, int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) { diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java index e87136021..ce9d64d6b 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -304,12 +304,23 @@ public final class LingxingWmsClient { int status = requiredInt(record.get("status"), "STATUS"); if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200); return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64), - requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime")); + requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"), listSkus(record.get("productList"))); }); return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(), page.sourceSize(), 200, page.observedAt(), metadata); } + private List listSkus(JsonNode products) { + if (products == null || products.isNull()) return null; + if (!products.isArray()) throw invalidResponse("PRODUCT_LIST_TYPE_INVALID", 200); + List items = new ArrayList<>(); + for (JsonNode item : products) { + if (!item.isObject()) throw invalidResponse("PRODUCT_LIST_ROW_INVALID", 200); + items.add(new ListSku(optionalText(item, "productSku"), optionalText(item, "productName"), quantity(item.get("qty")))); + } + return List.copyOf(items); + } + private QueryPage mappedPage(JsonNode data, int current, int size, String currentField, String sizeField, boolean outbound, AttemptMetadata metadata, Function projection) { JsonNode records = data.get("records"); diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml index e52a65bcb..9ebf851be 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -349,6 +349,16 @@ components: status: {type: integer} createTime: {type: string, nullable: true} outboundTime: {type: string, nullable: true} + productList: + type: array + nullable: true + description: Per-SKU list quantities; missing items, SKU or qty require one detail supplement for this order. Zero is a known quantity. + items: + type: object + properties: + productSku: {type: string, nullable: true} + productName: {type: string, nullable: true} + qty: {type: number, nullable: true} Warehouse: type: object required: [whCode, warehouseType, status] diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java index a911cc96d..a6de263c6 100644 --- a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java @@ -131,7 +131,7 @@ class LingxingInternalControllerTest { void authenticatedReadReturnsOnlyOutboundSummary() throws Exception { when(client.queryPage(any(), any())).thenReturn(new PageResult( List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100, - "2026-10-01 08:00:00", "2026-10-02 08:00:00")), + "2026-10-01 08:00:00", "2026-10-02 08:00:00", null)), 1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null)); mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) .contentType(MediaType.APPLICATION_JSON).content(BODY)) diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index d5b1deda4..2ba24e33a 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -81,6 +81,17 @@ class LingxingWmsClientTest { assertThat(result.observedAt()).isEqualTo(NOW); assertThat(result.pages()).isEqualTo(1); } + @Test void listProductsPreserveDecimalsZeroAndMissingQuantity() { + response.set(page("current","size",1,1,1,1,ORDER.strip().replace("}",""" + ,"productList":[{"productSku":"SKU","qty":2.123456},{"productSku":"ZERO","qty":0}, + {"productSku":"UNKNOWN"},{"qty":4}]}"""))); + var products=client().queryPage(query(1,1),context()).records().get(0).productList(); + assertThat(products).hasSize(4); + assertThat(products.get(0).qty()).isEqualByComparingTo("2.123456"); + assertThat(products.get(1).qty()).isEqualByComparingTo("0"); + assertThat(products.get(2).qty()).isNull();assertThat(products.get(3).productSku()).isNull(); + assertThat(calls.get()).isEqualTo(1); + } @Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception { response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", ""))); var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java index 0c44fb887..a492f4c2b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java @@ -21,5 +21,15 @@ public interface LingxingAnalyticsClient { Integer status, String timeZone) { } record Summary(String sourceAccount, String customerCode, String warehouseCode, String deliveryNo, String sourceNo, Integer sourceStatus, - String sourceCreatedTime, String sourceOutboundTime) { } + String sourceCreatedTime, String sourceOutboundTime, List items) { + public Order order() { + return new Order(sourceAccount, customerCode, warehouseCode, deliveryNo, sourceNo, sourceStatus, + sourceCreatedTime, sourceOutboundTime, items); + } + } + + static boolean needsDetail(Order order) { + return order.items() == null || order.items().isEmpty() || order.items().stream().anyMatch(item -> + item == null || item.skuCode() == null || item.skuCode().isBlank() || item.actualQuantity() == null); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java index 7c677fd5c..69435ef77 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java @@ -110,9 +110,20 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { } private Summary summary(String account, JsonNode n) { + List items = null; + JsonNode products = n.get("productList"); + if (products != null && !products.isNull()) { + if (!products.isArray()) throw failure("LIST_ITEMS_INVALID"); + items = new ArrayList<>(); + for (JsonNode item : products) { + if (!item.isObject()) throw failure("LIST_ITEM_INVALID"); + items.add(new SelfopPayloads.Item(optional(item, "productSku"), optional(item, "productName"), + quantity(item, "qty"), properties.getQuantityUnit())); + } + } return new Summary(account, required(n, "customerCode", 64), required(n, "whCode", 64), required(n, "deliveryNo", 128), optional(n, "sourceNo"), integer(n, "status"), - optional(n, "createTime"), optional(n, "outboundTime")); + optional(n, "createTime"), optional(n, "outboundTime"), items); } private void gate(SelfopRequestContext context) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java index 06228e752..10abe92fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java @@ -7,6 +7,7 @@ import java.util.Map; /** Durable directory cursor and effective immutable stage references. */ @Data public class SelfopRoutingManifest { + private int listPayloadVersion; private int scan = 1; private int nextPage = 1; private Integer pages; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java index 46752ce75..fa1ce6748 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java @@ -12,6 +12,7 @@ public final class SelfopBudget { if(limit<=0||seconds<=0)throw new IllegalArgumentException("SELFOP_BUDGET_INVALID"); this.limit=limit;this.deadline=Instant.now().plusSeconds(seconds); } - public boolean take() {if(requests>=limit||!Instant.now().isBefore(deadline))return false;requests++;return true;} + public boolean hasTime() {return Instant.now().isBefore(deadline);} + public boolean take() {if(requests>=limit||!hasTime())return false;requests++;return true;} public int used(){return requests;} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java index 11dec60f0..f7013b831 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -44,6 +44,13 @@ public class SelfopDiscoveryService { private void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget) { Query query=read(task.getQueryScopeJson(),Query.class); var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + if(manifest.getListPayloadVersion()==0) { + if(!manifest.isDirectoryComplete()) { + manifest.setScan(1);manifest.setNextPage(1);manifest.setPages(null);manifest.setTotal(null); + manifest.getObjects().clear();manifest.getFirstScan().clear(); + } + manifest.setListPayloadVersion(1); + } if(query.deliveryNo()!=null) {if(!collectLocated(task,run,lease,budget,query,manifest))return;} else { while(!manifest.isDirectoryComplete()) { @@ -60,7 +67,7 @@ public class SelfopDiscoveryService { String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));String content=hash(summary); if(manifest.getScan()==1){ require(!manifest.getFirstScan().containsKey(key),"SELFOP_DIRECTORY_DUPLICATE");manifest.getFirstScan().put(key,content); - var payload=new SelfopPayloads.Order(task.getSourceAccount(),summary.customerCode(),summary.warehouseCode(),summary.deliveryNo(),summary.sourceNo(),summary.sourceStatus(),summary.sourceCreatedTime(),summary.sourceOutboundTime(),null); + var payload=new SelfopPayloads.Order(task.getSourceAccount(),summary.customerCode(),summary.warehouseCode(),summary.deliveryNo(),summary.sourceNo(),summary.sourceStatus(),summary.sourceCreatedTime(),summary.sourceOutboundTime(),summary.items()); var stage=stage(task,run,payload,manifest.getNextPage(),null,"PENDING"); var prior=stages.selectOne(new QueryWrapper().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key)); if(prior==null)stages.insert(stage);else if("PENDING".equals(prior.getValidationStatus())){ @@ -87,6 +94,7 @@ public class SelfopDiscoveryService { } route(task,run,manifest,lease,false); for(var entry:manifest.getObjects().entrySet()) { + if(!budget.hasTime()){taskService.yield(task.getId(),run.getId(),lease);return;} var ref=entry.getValue(); if(ref.getRefreshTaskId()!=null){ var refresh=tasks.selectById(ref.getRefreshTaskId());require(refresh!=null,"SELFOP_REFRESH_TASK_MISSING"); @@ -105,7 +113,6 @@ public class SelfopDiscoveryService { var effective=ref.getEffectiveStageId()==null?null:stages.selectById(ref.getEffectiveStageId()); boolean newer=latest!=null&&(effective==null?latest.getObservationSequence()>task.getObservationBaseline():latest.getObservationSequence()>effective.getObservationSequence()); if(effective!=null&&!newer) continue; - if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;} leases.renew(lease); SelfopSyncTask fetchTask=task;SelfopSyncRun fetchRun=run; if(newer){ @@ -118,25 +125,47 @@ public class SelfopDiscoveryService { fetchRun=taskService.begin(fetchTask.getId(),lease); fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); } - long observation=leases.nextObservation(lease); - SelfopPayloads.Order detail; - try {detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());} - catch(AnalyticsClientException staleIdentity){ - if(!Set.of("DETAIL_IDENTITY_MISMATCH","INTEGRATION_DATA_MISSING","LINGXING_WMS_SOURCE_REJECTED","LINGXING_WMS_INVALID_RESPONSE").contains(staleIdentity.code())||staleIdentity.retryable())throw staleIdentity; - var relocate=new SelfopSyncTask();relocate.setSourceAccount(task.getSourceAccount());relocate.setTaskRole("ACCOUNT_DISCOVERY");relocate.setDiscoveryScopeId(task.getDiscoveryScopeId()); - relocate.setExecutionPurpose(task.getExecutionPurpose()); - relocate.setTaskType("RECHECK");relocate.setScopeHash(task.getScopeHash());relocate.setRequestKey("relocate:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+observation); - var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson())); - fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId()); - fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); + if(newer){ tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); - var located=new SelfopRoutingManifest(); - if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});taskService.yield(task.getId(),run.getId(),lease);return;} - ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode()); + collect(fetchTask,fetchRun,lease,budget); + var refreshedTask=tasks.selectById(fetchTask.getId()); + if(!"SUCCEEDED".equals(refreshedTask.getStatus())){taskService.yield(task.getId(),run.getId(),lease);return;} + var refreshed=read(refreshedTask.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(entry.getKey()); + require(refreshed!=null&&refreshed.getEffectiveStageId()!=null,"SELFOP_REFRESH_REFERENCE_MISSING"); + ref.setEffectiveStageId(refreshed.getEffectiveStageId());ref.setWarehouseCode(refreshed.getWarehouseCode()); tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); - if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;} - observation=leases.nextObservation(lease);detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo()); + continue; } + var directoryStage=ref.getDirectoryStageId()==null?null:stages.selectById(ref.getDirectoryStageId()); + SelfopPayloads.Order detail=directoryStage==null?null:read(directoryStage.getPayloadJson(),SelfopPayloads.Order.class); + long observation; + if(detail==null||LingxingAnalyticsClient.needsDetail(detail)) { + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;} + observation=leases.nextObservation(lease); + try {detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());} + catch(AnalyticsClientException staleIdentity){ + if(!Set.of("DETAIL_IDENTITY_MISMATCH","INTEGRATION_DATA_MISSING","LINGXING_WMS_SOURCE_REJECTED","LINGXING_WMS_INVALID_RESPONSE").contains(staleIdentity.code())||staleIdentity.retryable())throw staleIdentity; + var relocate=new SelfopSyncTask();relocate.setSourceAccount(task.getSourceAccount());relocate.setTaskRole("ACCOUNT_DISCOVERY");relocate.setDiscoveryScopeId(task.getDiscoveryScopeId()); + relocate.setExecutionPurpose(task.getExecutionPurpose()); + relocate.setTaskType("RECHECK");relocate.setScopeHash(task.getScopeHash());relocate.setRequestKey("relocate:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+observation); + var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson())); + fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId()); + fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); + tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); + var located=new SelfopRoutingManifest();located.setListPayloadVersion(1); + if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});taskService.yield(task.getId(),run.getId(),lease);return;} + ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode()); + tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); + var locatedRef=located.getObjects().values().iterator().next(); + var locatedStage=stages.selectById(locatedRef.getDirectoryStageId()); + detail=read(locatedStage.getPayloadJson(),SelfopPayloads.Order.class); + if(LingxingAnalyticsClient.needsDetail(detail)) { + if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;} + detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo()); + } + observation=leases.nextObservation(lease); + } + } else observation=leases.nextObservation(lease); require(Objects.equals(detail.sourceAccount(),task.getSourceAccount())&&Objects.equals(detail.customerCode(),ref.getCustomerCode()) &&Objects.equals(detail.deliveryNo(),ref.getDeliveryNo()),"SELFOP_DETAIL_IDENTITY_MISMATCH"); require(detail.items()!=null&&detail.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID"); @@ -163,7 +192,7 @@ public class SelfopDiscoveryService { }); route(task,run,manifest,lease,false); } - // A directory warehouse is provisional until every account detail (including resumed + // A directory warehouse is provisional until every account order (including resumed // revision checks) has resolved. An empty warehouse must not publish a false zero early. route(task,run,manifest,lease,true); tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);var currentTask=checkpoint.task();var currentRun=checkpoint.run();var now=utcNow(); @@ -191,7 +220,13 @@ public class SelfopDiscoveryService { require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); for(var summary:page.records())if(query.deliveryNo().equals(summary.deliveryNo())&&query.customerCode().equals(summary.customerCode())){ String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));require(!manifest.getObjects().containsKey(key),"SELFOP_LOCATE_AMBIGUOUS"); - var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode());manifest.getObjects().put(key,e);} + var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode()); + tx.executeWithoutResult(s->{taskService.checkpoint(task,run,lease); + var pending=stage(task,run,summary.order(),manifest.getNextPage(),null,"PENDING"); + var prior=stages.selectOne(new QueryWrapper().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key)); + if(prior==null)stages.insert(pending);else pending=prior; + e.setDirectoryStageId(pending.getId());}); + manifest.getObjects().put(key,e);} manifest.setNextPage(manifest.getNextPage()+1);if(page.pages()==0||manifest.getNextPage()>page.pages()){ require(manifest.getObjects().size()==1,"SELFOP_LOCATE_UNRESOLVED");manifest.setDirectoryComplete(true);} tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java index 4a2d84d10..1981528d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java @@ -88,7 +88,11 @@ public class SelfopValidationRunner { while(state.detailIndexclient.detail(context,summary.warehouseCode(),summary.customerCode(),summary.deliveryNo())); + SelfopPayloads.Order selected=summary.order(); + if(LingxingAnalyticsClient.needsDetail(selected)) { + selected=request(task,run,lease,()->client.detail(context,summary.warehouseCode(),summary.customerCode(),summary.deliveryNo())); + } + var detail=selected; validateOrder(task,summary,detail); tx.executeWithoutResult(s->{check(task,run,lease); boolean unknown=detail.items().stream().anyMatch(i->i.actualQuantity()==null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java index 7e9208102..cbd009edf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java @@ -74,6 +74,21 @@ class RestLingxingAnalyticsClientTest { assertThat(summary.warehouseCode()).isEqualTo("WH-B"); server.verify(); } + @Test void listItemsUseQtyAndKeepUnknownSeparateFromZero() { + enable();properties.setQuantityUnit("PCS"); + expect("/outbound-orders/locate", "{\"deliveryNo\":\"DO-1\",\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[ + {"deliveryNo":"DO-1","customerCode":"C","whCode":"WH-A","status":100, + "productList":[{"productSku":"SKU","qty":2.123456,"actualDeliveryQty":99}, + {"productSku":"ZERO","qty":0},{"productSku":"UNKNOWN"}]}]}} + """); + var summary=client.locate(context("synthetic-account"),"DO-1",1,100).records().get(0); + assertThat(summary.items().get(0).actualQuantity()).isEqualByComparingTo("2.123456"); + assertThat(summary.items().get(1).actualQuantity()).isEqualByComparingTo("0"); + assertThat(summary.items().get(2).actualQuantity()).isNull(); + assertThat(summary.items().get(0).quantityUnit()).isEqualTo("PCS"); + assertThat(LingxingAnalyticsClient.needsDetail(summary.order())).isTrue();server.verify(); + } @Test void detailProjectsActualLinesOnlyAndDoesNotInventUnitOrTimestamp() { enable(); expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java index b971ca49e..7f7c5f187 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java @@ -102,7 +102,7 @@ class SelfopDiscoveryServiceTest { } private LingxingAnalyticsClient.Summary summary(String no, String time) { - return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time); + return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time, null); } private SelfopPayloads.Order detail(String no, String time) { return new SelfopPayloads.Order("demo", "customer", "A", no, null, 100, null, time, @@ -240,7 +240,7 @@ class SelfopDiscoveryServiceTest { }).when(taskService).yield(anyLong(), anyLong(), eq(lease)); when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved"))) .thenThrow(new AnalyticsClientException("LINGXING_WMS_SOURCE_REJECTED", false)); - var moved = new LingxingAnalyticsClient.Summary("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00"); + var moved = new LingxingAnalyticsClient.Summary("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00", null); when(client.locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, moved)); when(client.detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"))).thenReturn(new SelfopPayloads.Order("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00", List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS")))); @@ -296,6 +296,85 @@ class SelfopDiscoveryServiceTest { assertEquals("ROUTED",run.getStatus());assertEquals(500L,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(key).getEffectiveStageId()); assertEquals(SelfopOutboundFingerprint.order(loaded),stored.get(500L).getContentHash()); } + private LingxingAnalyticsClient.Summary listed(String no, List items) { + var s=summary(no,"2026-10-11 12:00:00"); + return new LingxingAnalyticsClient.Summary(s.sourceAccount(),s.customerCode(),s.warehouseCode(),s.deliveryNo(), + s.sourceNo(),s.sourceStatus(),s.sourceCreatedTime(),s.sourceOutboundTime(),items); + } + @Test void completeListAndRealZeroNeedNoDetailsEvenWhenRequestBudgetIsExhausted() { + account.setRequestBudget(2); + when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1, + listed("one",List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("2.123456"),"PCS"))), + listed("zero",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ZERO,"PCS"))))); + service.collect(task,run,lease); + verify(client,never()).detail(any(),anyString(),anyString(),anyString()); + assertEquals("ROUTED",run.getStatus());assertEquals(2,stored.size()); + assertTrue(stored.values().stream().allMatch(s->"VALID".equals(s.getValidationStatus()))); + var quantities=stored.values().stream().map(s->read(s.getPayloadJson(),SelfopPayloads.Order.class).items().get(0).actualQuantity()).toList(); + assertTrue(quantities.stream().anyMatch(q->q.compareTo(new BigDecimal("2.123456"))==0)); + assertTrue(quantities.stream().anyMatch(q->q.signum()==0)); + } + @Test void completeListYieldsAtTimeLimitAndResumesWithoutRequests() { + when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1, + listed("one",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS"))), + listed("two",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.TEN,"PCS"))))); + try(var budgets=mockConstruction(SelfopBudget.class,(budget,context)->{ + when(budget.take()).thenReturn(true); + when(budget.hasTime()).thenReturn(true,false); + })) { + service.collect(task,run,lease); + verify(budgets.constructed().get(0),times(2)).take(); + } + verify(taskService).yield(task.getId(),run.getId(),lease); + assertEquals(1,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count()); + assertTrue(children.values().stream().allMatch(s->"WAITING_SOURCE".equals(s.getStatus()))); + service.collect(task,run,lease); + verify(client,times(2)).discover(any(),eq(from),eq(until),eq(1),eq(2)); + verify(client,never()).detail(any(),anyString(),anyString(),anyString()); + assertEquals(2,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count()); + assertEquals("ROUTED",run.getStatus()); + } + @Test void onlyIncompleteOrderFallsBackAndRetryDoesNotRefetchCompletedOrders() { + when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1, + listed("complete",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.TEN,"PCS"))), + listed("missing",List.of(new SelfopPayloads.Item("sku",null,null,"PCS"))))); + when(client.detail(any(),eq("A"),eq("customer"),eq("missing"))) + .thenThrow(new AnalyticsClientException("INTEGRATION_PROVIDER_FAILURE",true)) + .thenReturn(detail("missing","2026-10-11 12:00:00")); + assertThrows(AnalyticsClientException.class,()->service.collect(task,run,lease)); + assertEquals(1,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count()); + service.collect(task,run,lease); + verify(client,never()).detail(any(),eq("A"),eq("customer"),eq("complete")); + verify(client,times(2)).detail(any(),eq("A"),eq("customer"),eq("missing")); + verify(client,times(2)).discover(any(),eq(from),eq(until),eq(1),eq(2)); + assertEquals("ROUTED",run.getStatus()); + } + @Test void missingSkuOrEmptyItemsFallsBackPerOrder() { + when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1, + listed("blank",List.of(new SelfopPayloads.Item(" ",null,BigDecimal.ONE,"PCS"))),listed("empty",List.of()))); + service.collect(task,run,lease); + verify(client).detail(any(),eq("A"),eq("customer"),eq("blank")); + verify(client).detail(any(),eq("A"),eq("customer"),eq("empty")); + assertEquals("ROUTED",run.getStatus()); + } + @Test void genuinelyEmptyDirectoryDoesNotFetchDetails() { + when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,0,0)); + service.collect(task,run,lease); + verify(client,never()).detail(any(),anyString(),anyString(),anyString()); + assertEquals("ROUTED",run.getStatus());assertTrue(stored.isEmpty()); + } + @Test void partialOldDirectoryRestartsScansWithListItems() { + var old=new SelfopRoutingManifest();old.setScan(2);old.setNextPage(1);old.setPages(1);old.setTotal(1L); + old.getFirstScan().put(hash(List.of("demo","customer","one")),"old-summary-without-items"); + task.setRoutingManifestJson(json(old)); + when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,1,1, + listed("one",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS"))))); + service.collect(task,run,lease); + verify(client,times(2)).discover(any(),eq(from),eq(until),eq(1),eq(2)); + verify(client,never()).detail(any(),anyString(),anyString(),anyString()); + assertEquals("ROUTED",run.getStatus()); + assertEquals(1,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getListPayloadVersion()); + } @Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() { task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION"); assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java index 71b66aeef..ab291976f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java @@ -50,7 +50,7 @@ class SelfopValidationRunnerTest { } private LingxingAnalyticsClient.Page page(int current,long total,int pages,List records){return new LingxingAnalyticsClient.Page<>(records,current,10,total,pages);} private SelfopPayloads.Stock stock(String warehouse,String sku,BigDecimal qty){return new SelfopPayloads.Stock("demo","customer",warehouse,sku,0,"Name",qty,qty,BigDecimal.ZERO,BigDecimal.ZERO,null);} - private LingxingAnalyticsClient.Summary summary(String number){return new LingxingAnalyticsClient.Summary("demo","customer","A",number,"source",100,null,"2026-10-12 08:02:00");} + private LingxingAnalyticsClient.Summary summary(String number){return new LingxingAnalyticsClient.Summary("demo","customer","A",number,"source",100,null,"2026-10-12 08:02:00", null);} @Test void selectedWarehouseSampleOnlyStagesItsOwnRowsAndDoesNotClaimCompleteCoverage() { when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("A","one",BigDecimal.ZERO)))); @@ -186,7 +186,7 @@ class SelfopValidationRunnerTest { verify(client,never()).discover(any(SelfopRequestContext.class),any(),any(),anyInt(),anyInt());verifyNoInteractions(stages); } @Test void mixedAccountDirectoryOnlyFetchesDetailsOfFrozenWarehouse() { - var other=new LingxingAnalyticsClient.Summary("demo","customer","B","other","source",100,null,"2026-10-12 08:02:00"); + var other=new LingxingAnalyticsClient.Summary("demo","customer","B","other","source",100,null,"2026-10-12 08:02:00", null); when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,2,1,List.of(other,summary("selected")))); when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("selected"))) .thenReturn(new SelfopPayloads.Order("demo","customer","A","selected","source",100,null,"2026-10-12 08:02:00",List.of())); From c0e1d42b621477a345c5987cb473eae54d743274 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 08:55:58 +0800 Subject: [PATCH 66/80] perf(selfop): route collected orders once after resolution --- .../selfoperated/analytics/sync/SelfopDiscoveryService.java | 4 +++- .../analytics/sync/SelfopDiscoveryServiceTest.java | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java index f7013b831..01d40237f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -190,8 +190,10 @@ public class SelfopDiscoveryService { SelfopProgressProjection.freezeRun(currentFetchTask,currentFetchRun);currentFetchRun.setStatus("ROUTED");currentFetchRun.setFetchFinishedAtUtc(utcNow());currentFetchRun.setFinishedAtUtc(utcNow());currentFetchRun.setUpdatedAt(utcNow());runs.updateById(currentFetchRun); currentFetchTask.setStatus("SUCCEEDED");currentFetchTask.setUpdatedAt(utcNow());tasks.updateById(currentFetchTask);} }); - route(task,run,manifest,lease,false); } + // Child tasks stay WAITING_SOURCE until all orders are resolved. Rebuilding every + // warehouse manifest after each order made this loop quadratic in directory size. + // A resumed attempt projects its current references before entering the loop. // A directory warehouse is provisional until every account order (including resumed // revision checks) has resolved. An empty warehouse must not publish a false zero early. route(task,run,manifest,lease,true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java index 7f7c5f187..18df3af68 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java @@ -309,6 +309,9 @@ class SelfopDiscoveryServiceTest { service.collect(task,run,lease); verify(client,never()).detail(any(),anyString(),anyString(),anyString()); assertEquals("ROUTED",run.getStatus());assertEquals(2,stored.size()); + // Two orders, one warehouse: initial WAITING_SOURCE and final READY projections only. + verify(orders,times(4)).selectOne(any()); + assertTrue(children.values().stream().allMatch(child->"READY".equals(child.getStatus()))); assertTrue(stored.values().stream().allMatch(s->"VALID".equals(s.getValidationStatus()))); var quantities=stored.values().stream().map(s->read(s.getPayloadJson(),SelfopPayloads.Order.class).items().get(0).actualQuantity()).toList(); assertTrue(quantities.stream().anyMatch(q->q.compareTo(new BigDecimal("2.123456"))==0)); From 0aa188445b2082404cf6eafcb66eb3e6e95878c4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 09:29:08 +0800 Subject: [PATCH 67/80] fix(selfop): allow controlled continuous inventory retry in test --- .../analytics/sync/SelfopCommandService.java | 4 +-- .../sync/SelfopCommandServiceTest.java | 32 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java index f8910ce55..f7ebea27c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java @@ -56,7 +56,7 @@ public class SelfopCommandService { require("WAREHOUSE_SYNC".equals(original.getTaskRole())&&c.warehouseCode().equals(original.getWarehouseCode()),"SELFOP_TASK_SCOPE_MISMATCH"); require(!"WAITING_SOURCE".equals(original.getStatus()),"SELFOP_RETRY_WAITING_SOURCE"); require("BUSINESS".equals(original.getExecutionPurpose())&&"BUSINESS".equals(config.getCollectionMode()),"SELFOP_BUSINESS_PURPOSE_REQUIRED"); - require(!"test".equals(config.getEnvironment())||SelfopCollectionPolicy.controlledBusinessTask(original,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + require(!"test".equals(config.getEnvironment())||SelfopCollectionPolicy.controlledBusinessTask(original,tasks::selectById,SelfopCollectionPolicy.continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); return taskService.retry(original.getId(),c.requestKey(),operator,c.reason());} String purpose=SelfopCollectionPolicy.configuredPurpose(config); require(("VALIDATION".equals(purpose))==("VALIDATION".equals(c.action())),"SELFOP_VALIDATION_ENTRY_REQUIRED"); @@ -123,6 +123,6 @@ public class SelfopCommandService { Map.entry("SELFOP_TASK_ALREADY_SUCCEEDED","任务已成功完成,无需重试"), Map.entry("SELFOP_COLLECTION_MODE_REJECTED","当前采集模式不允许此操作"), Map.entry("SELFOP_BUSINESS_ENVIRONMENT_REJECTED","当前环境不允许正式采集"), - Map.entry("SELFOP_TEST_MANUAL_TASK_REQUIRED","测试环境仅允许重试人工采集任务及其派生任务"), + Map.entry("SELFOP_TEST_MANUAL_TASK_REQUIRED","测试环境仅允许重试当前已启用的受控采集任务"), Map.entry("SELFOP_BUDGET_INVALID","采集预算配置无效,请联系管理员")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java index 365a57371..e45cfa108 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java @@ -117,11 +117,39 @@ class SelfopCommandServiceTest { verify(taskService,never()).create(any(SelfopSyncTask.class)); } @Test void testCannotUseRetryToReviveAutomaticScheduledWork() { - configure();config.setEnvironment("test");var original=original("synthetic-account","WH-A"); + configure();config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true);var original=original("synthetic-account","WH-A"); original.setTaskType("SCHEDULED");original.setRequestKey("slot:old");when(tasks.selectOne(any())).thenReturn(original); - assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("测试环境仅允许重试人工采集任务及其派生任务"); + assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("测试环境仅允许重试当前已启用的受控采集任务"); verify(taskService,never()).retry(anyLong(),anyString(),anyString(),anyString()); } + @Test void enabledContinuousInventoryCanResumeItsOriginalScheduledIdentity() { + configure();config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true); + var original=continuousInventory();when(tasks.selectOne(any())).thenReturn(original); + when(taskService.retry(anyLong(),anyString(),anyString(),anyString())).thenReturn(original); + assertThat(service.request(retry(),"42")).isSameAs(original); + verify(taskService).retry(eq(original.getId()),eq(retry().requestKey()),eq("42"),eq(retry().reason())); + verify(taskService,never()).create(any(SelfopSyncTask.class)); + assertThat(original.getTaskType()).isEqualTo("SCHEDULED"); + assertThat(original.getRequestedBy()).isEqualTo(SelfopCollectionPolicy.CONTINUOUS_PLANNER); + assertThat(original.getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026,10,14,0,0)); + } + @Test void continuousRetryStillRejectsDisabledModeAndForgedPlannerIdentity() { + configure();config.setEnvironment("test");var original=continuousInventory(); + when(tasks.selectOne(any())).thenReturn(original); + assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("测试环境仅允许重试当前已启用的受控采集任务"); + config.setContinuousBusinessCollectionEnabled(true);original.setRequestedBy("42"); + assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("测试环境仅允许重试当前已启用的受控采集任务"); + verify(taskService,never()).retry(anyLong(),anyString(),anyString(),anyString()); + } + private SelfopSyncTask continuousInventory() { + var task=original("synthetic-account","WH-A");task.setScopeId(10L);task.setScopeHash("a".repeat(64)); + task.setTaskType("SCHEDULED");task.setPlannedAtUtc(LocalDateTime.of(2026,10,14,0,0)); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setBusinessRootTaskId(task.getId()); + task.setRequestKey("continuous:inventory:WH-A:"+task.getPlannedAtUtc()+":"+task.getScopeHash().substring(0,12)); + task.setQueryScopeJson(com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.json(Map.of( + "sourceAccount","synthetic-account","warehouseCode","WH-A","dataFamily","INVENTORY","businessTimezone","Asia/Shanghai"))); + return task; + } @Test void validationModeRejectsFormalInventoryEntry() { configure();config.setCollectionMode("VALIDATION"); var c=new SelfopCommandService.Command("wrong-purpose","WH-A","INVENTORY",null,null,null,null,null,"offline test"); From 6024faa9aa901426758cb207cf5fbe28720d69f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 09:36:52 +0800 Subject: [PATCH 68/80] fix(selfop): isolate inventory paging and bound unstable scan retries --- .../model/SelfopAnalyticsProperties.java | 2 + .../sync/SelfopInventoryCollector.java | 6 ++- .../analytics/sync/SelfopTaskService.java | 10 +++- .../sync/SelfopInventoryCollectorTest.java | 45 ++++++++++++++++- .../sync/SelfopInventoryRecoveryTest.java | 2 +- .../analytics/sync/SelfopTaskServiceTest.java | 49 +++++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java index adebbd356..c78c36466 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java @@ -53,6 +53,8 @@ public class SelfopAnalyticsProperties { private int leaseSeconds = 180; private int maxAttempts = 5; private int pageSize = 100; + /** Inventory supports larger pages independently of the outbound list limit. */ + private int inventoryPageSize = 1000; private int applyBatchSize = 100; private Map warehouses = new LinkedHashMap<>(); @Data diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java index b63a1c2b2..7b166a30d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java @@ -25,12 +25,14 @@ public class SelfopInventoryCollector { } public boolean collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease){ SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run); + int pageSize=config.getInventoryPageSize(); + require(pageSize>=1&&pageSize<=1000,"SELFOP_INVENTORY_PAGE_SIZE_INVALID"); var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); var budget=new SelfopBudget(config.getRequestBudget(),config.getTimeBudgetSeconds()); while(!manifest.isDirectoryComplete()){ if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return false;} - leases.renew(lease);var page=client.inventory(SelfopRequestContext.forTask(task,run,config),task.getWarehouseCode(),manifest.getNextPage(),config.getPageSize()); - require(page.current()==manifest.getNextPage()&&page.size()==config.getPageSize(),"SELFOP_PAGE_MISMATCH"); + leases.renew(lease);var page=client.inventory(SelfopRequestContext.forTask(task,run,config),task.getWarehouseCode(),manifest.getNextPage(),pageSize); + require(page.current()==manifest.getNextPage()&&page.size()==pageSize,"SELFOP_PAGE_MISMATCH"); require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING"); if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());} require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java index f045b98f6..0a9986582 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -198,10 +198,15 @@ public class SelfopTaskService { run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); // A yielded execution is not a failed request; finite failures are counted separately. long failures=runs.selectCount(new QueryWrapper().eq("task_id",taskId).eq("status","FAILED")); - if(task.getBusinessRootTaskId()!=null&&continuousBusinessRoot(tasks.selectById(task.getBusinessRootTaskId())))retryable=false; + boolean inventory=task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily()); + if(task.getBusinessRootTaskId()!=null&&continuousBusinessRoot(tasks.selectById(task.getBusinessRootTaskId()))) { + // A completed response with an unstable inventory scan can be read again from page one. + // Authentication, rate limits, uncertain transport results and other families retain their stop policy. + retryable=retryable&&inventory&&Set.of("SELFOP_STOCK_DUPLICATE","SELFOP_STOCK_COUNT_MISMATCH","SELFOP_PAGE_TOTAL_CHANGED").contains(code); + } task.setStatus(retryable&&failures().eq("id",taskId).set("routing_manifest_json",null)); } @@ -210,6 +215,7 @@ public class SelfopTaskService { tasks.update(null,new UpdateWrapper().eq("id",taskId).set("routing_manifest_json",null)); } task.setNextRetryAtUtc("DEAD".equals(task.getStatus())?null:now.plusSeconds(Math.min(3600,5L<().eq("id",taskId).set("next_retry_at_utc",null)); } @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public SelfopSyncTask retry(long id,String requestKey,String operator,String reason) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java index d4cee11cf..eabe2f65c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java @@ -39,12 +39,36 @@ class SelfopInventoryCollectorTest { Consumer callback = invocation.getArgument(0); callback.accept(mock(TransactionStatus.class)); return null; }).when(tx).executeWithoutResult(any()); - config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setInventoryPageSize(2); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx); task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(20L); task.setSourceAccount("demo"); task.setWarehouseCode("A"); run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(10L); run.setTaskId(20L); run.setStatus("FETCHING"); run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setFetchStartedAtUtc(utcNow()); } + @Test void defaultInventoryPageSizeCollects1001RowsInTwoRequestsWithoutChangingOutboundSize() { + var defaults=new SelfopAnalyticsProperties(); + assertEquals(100,defaults.getPageSize());assertEquals(1000,defaults.getInventoryPageSize()); + config.setInventoryPageSize(defaults.getInventoryPageSize()); + var first=new ArrayList(); + for(int i=0;i<1000;i++)first.add(stock("sku-"+i,BigDecimal.ONE)); + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(1000))) + .thenReturn(new LingxingAnalyticsClient.Page<>(first,1,1000,1001L,2)); + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(2),eq(1000))) + .thenReturn(new LingxingAnalyticsClient.Page<>(List.of(stock("last",BigDecimal.ZERO)),2,1000,1001L,2)); + assertTrue(collector.collect(task,run,lease)); + assertEquals(100,config.getPageSize());assertEquals(1001L,run.getUniqueObjectCount()); + assertEquals("COMPLETE",run.getCoverageStatus());assertEquals(2,run.getPageCount()); + verify(client,times(2)).inventory(any(SelfopRequestContext.class),eq("A"),anyInt(),eq(1000)); + verify(stages,times(1001)).insert(any(SelfopSyncStage.class)); + } + @Test void invalidInventoryPageSizeRejectsBeforeAnyRequestOrStageWrite() { + for(int size:new int[]{0,-1,1001}){ + config.setInventoryPageSize(size); + assertEquals("SELFOP_INVENTORY_PAGE_SIZE_INVALID",assertThrows(IllegalStateException.class, + ()->collector.collect(task,run,lease)).getMessage()); + } + verifyNoInteractions(client,stages,leases); + } @Test void lateInventoryResponseCannotWriteStagesOrReviveStoppedAttempt() { when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2))).thenReturn(page(1,1,1,stock("late",BigDecimal.ONE))); when(taskService.checkpoint(task,run,lease)).thenThrow(new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE")); @@ -103,6 +127,25 @@ class SelfopInventoryCollectorTest { assertFalse(read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete()); assertIncompleteAttemptWithPersistedProgress(); } + @Test void freshAttemptAfterDuplicateReReadsAllPagesAndKeepsFailedRowsOutOfItsBatch() { + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2))) + .thenReturn(page(1,3,2,stock("a",BigDecimal.ONE),stock("b",BigDecimal.ZERO))); + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(2),eq(2))) + .thenReturn(page(2,3,2,stock("a",BigDecimal.ONE)),page(2,3,2,stock("c",BigDecimal.TEN))); + assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)); + assertNull(run.getFetchFinishedAtUtc()); + // SelfopTaskService.fail/begin clear the failed cursor and allocate a fresh run. + task.setRoutingManifestJson(null); + var next=new SelfopSyncRun();next.setId(11L);next.setTaskId(task.getId());next.setAttemptNo(2); + next.setExecutionPurpose("BUSINESS");next.setSourceAccount("demo");next.setStatus("FETCHING"); + next.setFetchStartedAtUtc(utcNow());next.setScopeSnapshotJson(run.getScopeSnapshotJson()); + assertTrue(collector.collect(task,next,lease)); + verify(client,times(2)).inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2)); + var captured=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(5)).insert(captured.capture()); + assertEquals(3,captured.getAllValues().stream().filter(s->Objects.equals(s.getStageBatchId(),11L)).count()); + assertEquals(3L,next.getSourceTotalCount());assertEquals(3L,next.getUniqueObjectCount()); + assertEquals("COMPLETE",next.getCoverageStatus());assertNull(run.getCoverageStatus()); + } @Test void leakedWarehouseIsRejectedWithoutCompletingRun() { var leaked = new SelfopPayloads.Stock("demo", "customer", "B", "sku", 0, null, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java index 3920dea7b..2571e9c0d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java @@ -50,7 +50,7 @@ class SelfopInventoryRecoveryTest { var sales = new SelfopSyncScope(); sales.setId(30L); sales.setDataFamily("OUTBOUND"); sales.setPublishedRunId(80L); when(locks.lockTask(1L)).thenReturn(task); when(locks.lockRun(20L)).thenReturn(original); when(locks.lockScope(30L)).thenReturn(sales); when(scopes.selectById(10L)).thenReturn(inventory); when(scopes.selectOne(any())).thenReturn(sales); - config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); config.setPageSize(2); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); config.setInventoryPageSize(2); } @Test void budgetYieldResumesSameAttemptWithFixedReferencesAndOriginalObservationStart() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java index a0753b047..f1d89dfb2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java @@ -468,6 +468,55 @@ class SelfopTaskServiceTest { assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull(); assertThat(run.getStatus()).isEqualTo("FAILED");assertThat(run.getErrorCode()).isEqualTo("LINGXING_WMS_RATE_LIMITED"); } + @Test void continuousInventoryScanFailureRetriesFromPageOneWithinFailureBudget(){ + for(String code:List.of("SELFOP_STOCK_DUPLICATE","SELFOP_STOCK_COUNT_MISMATCH","SELFOP_PAGE_TOTAL_CHANGED")) { + var root=continuousInventoryRoot();root.setRoutingManifestJson("{\"nextPage\":6}");root.setAttemptCount(99); + var failed=run("FETCHING");when(locks.lockRun(20L)).thenReturn(failed); + when(runs.selectCount(any())).thenReturn(1L);config.setMaxAttempts(3); + var before=utcNow();service.fail(1L,20L,code,true,lease); + assertThat(root.getStatus()).isEqualTo("RETRY_WAIT"); + assertThat(root.getNextRetryAtUtc()).isBetween(before.plusSeconds(10),utcNow().plusSeconds(10)); + assertThat(root.getRoutingManifestJson()).isNull(); + assertThat(failed.getStatus()).isEqualTo("FAILED");assertThat(failed.getErrorCode()).isEqualTo(code); + assertThat(root.getTaskType()).isEqualTo("SCHEDULED"); + assertThat(root.getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026,10,14,0,0)); + var fresh=service.begin(1L,lease); + assertThat(fresh.getId()).isNotEqualTo(failed.getId()); + assertThat(fresh.getCoverageStatus()).isEqualTo("UNKNOWN"); + assertThat(fresh.getInventoryDailySampleDate()).isNull(); + assertThat(root.getRoutingManifestJson()).isNull(); + } + } + @Test void continuousInventoryScanFailureStopsAtConfiguredLimitAndClearsPersistedRetryTime(){ + var root=continuousInventoryRoot();root.setNextRetryAtUtc(utcNow().minusMinutes(1)); + when(locks.lockRun(20L)).thenReturn(run("FETCHING"));when(runs.selectCount(any())).thenReturn(3L);config.setMaxAttempts(3); + service.fail(1L,20L,"SELFOP_STOCK_DUPLICATE",true,lease); + assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull(); + verify(tasks,atLeastOnce()).update(isNull(),argThat((com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper update)-> + update.getSqlSet().contains("next_retry_at_utc"))); + } + @Test void continuousInventoryProtectionAndIdentityErrorsNeverAutomaticallyRetry(){ + for(String code:List.of("LINGXING_WMS_RATE_LIMITED","LINGXING_WMS_AUTHORIZATION_REJECTED", + "INTEGRATION_TRANSPORT_FAILURE","SELFOP_STOCK_SCOPE_LEAK","SELFOP_STOCK_IDENTITY_MISSING","SELFOP_EXECUTION_FAILED")) { + var root=continuousInventoryRoot();when(locks.lockRun(20L)).thenReturn(run("FETCHING"));when(runs.selectCount(any())).thenReturn(1L); + service.fail(1L,20L,code,true,lease); + assertThat(root.getStatus()).as(code).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull(); + } + var root=continuousInventoryRoot();when(locks.lockRun(20L)).thenReturn(run("FETCHING")); + service.fail(1L,20L,"SELFOP_STOCK_DUPLICATE",false,lease); + assertThat(root.getStatus()).isEqualTo("DEAD"); + } + private SelfopSyncTask continuousInventoryRoot(){ + var root=boundedRoot(0,utcNow().plusMinutes(3));config.setContinuousBusinessCollectionEnabled(true); + root.setTaskType("SCHEDULED");root.setStatus("RUNNING");root.setApplyState("NOT_APPLICABLE"); + root.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);root.setScopeHash("b".repeat(64)); + root.setPlannedAtUtc(LocalDateTime.of(2026,10,14,0,0)); + root.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12)); + root.setQueryScopeJson(json(Map.of("dataFamily","INVENTORY","businessTimezone","Asia/Shanghai","sourceAccount","synthetic-account","warehouseCode","WH-A"))); + root.setBusinessBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.freezeContinuous(utcNow())); + when(scopes.selectById(10L)).thenReturn(scope("INVENTORY")); + return root; + } @Test void egressStopReasonSurvivesExecutorFailureHandling() { var root=boundedRoot(7,utcNow().plusMinutes(3)); var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); From 2e2a354ba60635fa3bc8c59f2a41f535fe120ac3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 09:37:00 +0800 Subject: [PATCH 69/80] fix(selfop): retain turnover precision and scope reused publication runs --- .../mapper/SelfopCoordinationMapper.java | 15 ++- .../query/InventoryTurnoverPolicy.java | 6 +- .../analytics/query/InventoryTurnoverSql.java | 11 +- .../analytics/sync/SelfopSalesPublisher.java | 2 +- .../query/InventoryTurnoverPolicyTest.java | 38 +++++++ .../query/InventoryTurnoverSqlTest.java | 60 ++++++++++- .../sync/SelfopReusedRunScopeSqlTest.java | 102 ++++++++++++++++++ .../sync/SelfopSalesPublisherTest.java | 3 + 8 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReusedRunScopeSqlTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java index 2415972b9..b6465397d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java @@ -23,8 +23,19 @@ public interface SelfopCoordinationMapper { long barriers(@Param("account") String account, @Param("warehouse") String warehouse); @Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND apply_state='APPLIED' AND (query_from_utc IS NULL OR (query_from_utc>=#{from} AND query_until_utc<=#{until})) AND status IN ('READY','RUNNING','RETRY_WAIT','DEAD')") int completeCoveredTasks(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("from") LocalDateTime from,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now); - @Update("UPDATE ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id SET r.status='REUSED',r.finished_at_utc=#{now},r.updated_at=#{now},r.progress_snapshot_json=COALESCE(r.progress_snapshot_json,CASE WHEN JSON_EXTRACT(t.progress_json,'$.attemptNo')=r.attempt_no THEN t.progress_json ELSE NULL END) WHERE t.result_published_run_id=#{published} AND r.id<>#{published} AND r.attempt_no=t.attempt_count AND r.execution_purpose='BUSINESS' AND r.status IN ('FETCHING','FACTS_APPLIED','BUILDING','READY')") - int completeReusedRuns(@Param("published") long published,@Param("now") LocalDateTime now); + @Update(""" + UPDATE ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id + SET r.status='REUSED',r.finished_at_utc=#{now},r.updated_at=#{now},r.progress_snapshot_json=COALESCE(r.progress_snapshot_json,CASE WHEN JSON_EXTRACT(t.progress_json,'$.attemptNo')=r.attempt_no THEN t.progress_json ELSE NULL END) + WHERE r.scope_id=#{scope} AND t.scope_id=#{scope} + AND r.source_account=#{account} AND t.source_account=#{account} + AND r.warehouse_code=#{warehouse} AND t.warehouse_code=#{warehouse} + AND r.scope_hash=#{hash} AND t.scope_hash=#{hash} + AND t.result_published_run_id=#{published} AND r.id<>#{published} + AND r.attempt_no=t.attempt_count AND r.execution_purpose='BUSINESS' AND t.execution_purpose='BUSINESS' + AND r.status IN ('FETCHING','FACTS_APPLIED','BUILDING','READY') + """) + int completeReusedRuns(@Param("scope") long scope,@Param("account") String account,@Param("warehouse") String warehouse, + @Param("hash") String hash,@Param("published") long published,@Param("now") LocalDateTime now); @Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND task_type='SCHEDULED' AND apply_state='NOT_APPLICABLE' AND status='WAITING_SOURCE' AND planned_at_utc<=#{until}") int completeCoveredMarkers(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now); @Update("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java index 505b6906d..b0a83256e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java @@ -49,11 +49,13 @@ public final class InventoryTurnoverPolicy { var r=normalized(raw);boolean owner="OWNER".equals(q.group()); Total physical=total(r,"physical",owner),available=total(r,"available",owner); var windows=windows(r,context.endDate().plusDays(1));var base=windows.stream().filter(w->w.days()==q.days()).findFirst().orElseThrow(); - var coverage=owner?null:scale(decimal(r,"coverage_days"));var average=scale(decimal(r,"average_physical")); + var coverage=owner?null:scale(decimal(r,"coverage_days"));var rawAverage=decimal(r,"average_physical");var average=scale(rawAverage); var turnover=scale(decimal(r,"turnover"));int sampleCount=count(r,"sample_count"); String coverageStatus=owner?"NOT_APPLICABLE":available.value()==null?"INVENTORY_UNKNOWN_OR_STALE":available.value().signum()==0?"NO_AVAILABLE_STOCK": base.outboundQuantity()==null||count(r,"unassessable")>0?"UNASSESSABLE":base.outboundQuantity().signum()==0?"NO_OUTBOUND_IN_WINDOW":"VALID"; - String sampleStatus=sampleCount missing=new ArrayList<>();String dates=text(r,"missing_dates"); if(dates!=null&&!dates.isBlank())for(String date:dates.split(","))missing.add(LocalDate.parse(date.substring(0,10))); List attention=new ArrayList<>(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index cc7fdb683..12d76479b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -69,12 +69,12 @@ public final class InventoryTurnoverSql { .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid"); for(int n:List.of(7,15,30)) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix); - sql.append(",w.sample_count,w.average_physical,w.sample_mask,ls.last_outbound"); + sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound"); for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field); sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ") .append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,") .append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 AND evidence<=#{p.baseFromUtc} THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,") - .append("CASE WHEN average_physical>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound/average_physical END turnover,") + .append("CASE WHEN sample_total>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,") .append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w30_outbound IS NOT NULL AND evidence<=#{p.fromUtc} THEN CASE WHEN w30_outbound=0 THEN 1 ELSE 0 END END no_outbound,") .append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL OR evidence IS NULL OR evidence>#{p.baseFromUtc} THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (") .append("SELECT b.*,CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w") @@ -115,8 +115,10 @@ public final class InventoryTurnoverSql { .append("JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=st.customer_code AND ip.sku_code=st.sku_code ") .append("WHERE ip.quantity_unit='PCS' AND st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,") .append("COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") + // Keep the qualified sum exact; do not divide again by a rounded daily mean. + .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,") .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,") - .append(sampleMask()).append(" sample_mask FROM sku_sample_days GROUP BY customer_code,sku_code), sku_window_counts AS (SELECT z.customer_code,z.sku_code,COALESCE(s.sample_count,0) sample_count,s.average_physical,COALESCE(s.sample_mask,0) sample_mask"); + .append(sampleMask()).append(" sample_mask FROM sku_sample_days GROUP BY customer_code,sku_code), sku_window_counts AS (SELECT z.customer_code,z.sku_code,COALESCE(s.sample_count,0) sample_count,s.sample_total,s.average_physical,COALESCE(s.sample_mask,0) sample_mask"); for(int n:List.of(7,15,30)) sql.append(",CASE WHEN z.w").append(n).append("_known+COALESCE(a.w").append(n).append("_known,0)>0 THEN COALESCE(a.w").append(n).append("_sum,0) END w").append(n).append("_observed") .append(",z.w").append(n).append("_complete+COALESCE(a.w").append(n).append("_complete,0) w").append(n).append("_complete") .append(",z.w").append(n).append("_partial+COALESCE(a.w").append(n).append("_partial,0) w").append(n).append("_partial"); @@ -167,10 +169,11 @@ public final class InventoryTurnoverSql { +"s.sample_physical FROM owner_candidates c CROSS JOIN calendar d LEFT JOIN owner_actual_days a ON a.customer_code=c.customer_code AND a.business_date=d.business_date " +"LEFT JOIN owner_sample_days s ON s.customer_code=c.customer_code AND s.business_date=d.business_date), owner_window_counts AS (SELECT customer_code" +windowAggregates()+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," + +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total," +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical," +sampleMask()+" sample_mask FROM owner_days GROUP BY customer_code), owner_windows AS (SELECT w.*"+formalWindows()+" FROM owner_window_counts w), owner_totals AS (" +totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.sample_mask," - +"CASE WHEN w.average_physical>0 THEN w.w"+p.query.days()+"_outbound/w.average_physical END turnover," + +"CASE WHEN w.sample_total>0 THEN w.w"+p.query.days()+"_outbound*#{p.query.days}/w.sample_total END turnover," +String.join(",",List.of(7,15,30).stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList()) +" FROM owner_totals t JOIN owner_windows w ON w.customer_code=t.customer_code) "; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java index 90f8ec677..9f6a656fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java @@ -45,7 +45,7 @@ public class SelfopSalesPublisher { run.setStatus("PUBLISHED");run.setPublishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); task.setResultPublishedRunId(runId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task); locks.completeCoveredTasks(scopeId,scope.getScopeHash(),runId,run.getCoveredFromUtc(),run.getCoveredUntilUtc(),now); - locks.completeReusedRuns(runId,now); + locks.completeReusedRuns(scopeId,scope.getSourceAccount(),scope.getWarehouseCode(),scope.getScopeHash(),runId,now); locks.completeCoveredMarkers(scopeId,scope.getScopeHash(),runId,run.getCoveredUntilUtc(),now); } @Transactional diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicyTest.java new file mode 100644 index 000000000..ece0ea4c9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicyTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; + +class InventoryTurnoverPolicyTest { + static Context context() { + return new Context(null,"demo","W",InventoryTurnoverSqlTest.END,30,null,null,null,null,null,null,List.of(),List.of(),null); + } + private Row mapped(String average,String turnover,int samples) { + var raw=new HashMap();raw.put("average_physical",average);raw.put("turnover",turnover);raw.put("sample_count",samples); + raw.put("w30_outbound",BigDecimal.ONE);raw.put("w30_complete",30); + return InventoryTurnoverPolicy.row(raw,InventoryTurnoverSqlTest.request("SKU","ALL","TURNOVER_DESC",null,null,1,20),context()); + } + @Test void tinyPositiveAverageQualifiesBeforeSixPlaceOutputRounding() { + var row=mapped("0.0000000333","30000000",30); + assertEquals("VALID",row.turnoverStatus());assertTrue(row.sampleReasonCodes().isEmpty()); + assertEquals(new BigDecimal("0.000000"),row.averagePhysicalQuantity()); + assertEquals(new BigDecimal("30000000.000000"),row.quantityTurnoverCount()); + } + @Test void zeroUnknownAndIncompleteSamplesCannotLeakATurnoverValue() { + for(var input:List.of(new String[]{"0","ZERO_AVERAGE_STOCK"},new String[]{null,"INSUFFICIENT"})) { + var row=mapped(input[0],"30000000",30);assertNull(row.quantityTurnoverCount()); + assertEquals(input[1],row.turnoverStatus());assertEquals(List.of(input[1]),row.sampleReasonCodes()); + } + var row=mapped("1","30000000",29);assertNull(row.quantityTurnoverCount()); + assertEquals("INSUFFICIENT",row.turnoverStatus());assertEquals(List.of("INSUFFICIENT"),row.sampleReasonCodes()); + } + @Test void positiveAverageSeparatesMissingSalesFromKnownZeroSales() { + var missing=mapped("0.0000000333",null,30);assertNull(missing.quantityTurnoverCount()); + assertEquals("SALES_WINDOW_UNAVAILABLE",missing.turnoverStatus());assertEquals(List.of("SALES_WINDOW_UNAVAILABLE"),missing.sampleReasonCodes()); + var zero=mapped("0.0000000333","0",30);assertEquals("VALID",zero.turnoverStatus()); + assertEquals(new BigDecimal("0.000000"),zero.quantityTurnoverCount()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index af7bed0fe..053b3bf6f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -362,12 +362,70 @@ class InventoryTurnoverSqlTest { assertEquals("SKU".equals(group)?0:3,report.stream().filter(r->"ROW".equals(r.get("section"))).count()); } } + @Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception { + seed("A","ONE",30);seed("A","TWO",30); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN snapshot_run_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE snapshot_run_id>=100"); + execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'"); + for(String group:List.of("SKU","OWNER")) { + var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true); + for(var row:reportRows(p)) { + assertTrue(new BigDecimal(row.get("average_physical").toString()).signum()>0); + decimal("30000000",row.get("turnover"));decimal("30",row.get("sample_count")); + var mapped=InventoryTurnoverPolicy.row(row,q,InventoryTurnoverPolicyTest.context()); + assertEquals("VALID",mapped.turnoverStatus());assertTrue(mapped.sampleReasonCodes().isEmpty()); + assertEquals(new BigDecimal("0.000000"),mapped.averagePhysicalQuantity()); + assertEquals(new BigDecimal("30000000.000000"),mapped.quantityTurnoverCount()); + } + for(var row:query("page",p))decimal("30000000",row.get("turnover")); + } + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); + for(String group:List.of("SKU","OWNER")) { + var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20); + for(var row:reportRows(input(q,30,30,true))) { + decimal("0",row.get("average_physical"));assertNull(row.get("turnover")); + var mapped=InventoryTurnoverPolicy.row(row,q,InventoryTurnoverPolicyTest.context()); + assertEquals("ZERO_AVERAGE_STOCK",mapped.turnoverStatus());assertNull(mapped.quantityTurnoverCount()); + assertEquals(List.of("ZERO_AVERAGE_STOCK"),mapped.sampleReasonCodes()); + } + for(var row:reportRows(input(q,30,29,true))) { + assertNull(row.get("average_physical"));assertNull(row.get("turnover")); + var mapped=InventoryTurnoverPolicy.row(row,q,InventoryTurnoverPolicyTest.context()); + assertEquals("INSUFFICIENT",mapped.turnoverStatus());assertNull(mapped.quantityTurnoverCount()); + } + } + } + @Test void unroundedThresholdAndStableTiesSurviveActualReportPagination() throws Exception { + seed("OA","HIGH",30);seed("OA","A",30);seed("OZ","A",30);seed("OA","LOW",30); + execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=CASE sku_code WHEN 'HIGH' THEN 200.000001 WHEN 'LOW' THEN 199.999999 ELSE 200 END WHERE snapshot_run_id=22 AND product_quality=0"); + var gt=request("SKU","COVERAGE_GT","COVERAGE_DESC",null,20,1,20); + var greater=reportRows(input(gt,30,30,true));assertEquals(List.of("HIGH"),greater.stream().map(r->r.get("sku_code")).toList()); + assertEquals(new BigDecimal("20.000000"),InventoryTurnoverPolicy.row(greater.get(0),gt,InventoryTurnoverPolicyTest.context()).inventoryCoverageDays()); + var le=request("SKU","COVERAGE_LE","COVERAGE_DESC",null,20,1,20); + assertEquals(List.of("A/OA","A/OZ","LOW/OA"),reportRows(input(le,30,30,true)).stream().map(r->r.get("sku_code")+"/"+r.get("customer_code")).toList()); + var expected=List.of("HIGH/OA","A/OA","A/OZ","LOW/OA"); + for(int page=1;page<=4;page++) { + var q=request("SKU","ALL","COVERAGE_DESC",null,20,page,1);var result=reportRows(input(q,30,30,true)); + assertEquals(1,result.size());assertEquals(expected.get(page-1),result.get(0).get("sku_code")+"/"+result.get(0).get("customer_code")); + } + } + @Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception { + seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE snapshot_run_id>=100"); + execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'"); + for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) { + var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1); + var result=reportRows(input(q,30,30,true));assertEquals(1,result.size()); + assertEquals(List.of("Z","A","B","L").get(page-1),result.get(0).get("customer_code")); + var mapped=InventoryTurnoverPolicy.row(result.get(0),q,InventoryTurnoverPolicyTest.context()); + assertEquals("VALID",mapped.turnoverStatus());assertEquals(new BigDecimal("1.000000"),mapped.quantityTurnoverCount()); + } + } private static com.fasterxml.jackson.databind.JsonNode payload(Map row) throws Exception { var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);Object raw=row.get("payload"); return raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString()); } private List> reportRows(InventoryTurnoverSql.Input p) throws Exception { - var result=new ArrayList>();var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var result=new ArrayList>();var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); for(var row:query("report",p)) if("ROW".equals(row.get("section"))) result.add(json.convertValue(payload(row),new com.fasterxml.jackson.core.type.TypeReference>(){})); return result; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReusedRunScopeSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReusedRunScopeSqlTest.java new file mode 100644 index 000000000..e4c6f3946 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReusedRunScopeSqlTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.scripting.defaults.DefaultParameterHandler; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.sql.*; +import java.time.LocalDateTime; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Executes production JOIN/predicates/assignments offline. Only MySQL UPDATE JOIN syntax is + * adapted to H2 MERGE; this does not prove MySQL optimizer choices or lock isolation. */ +public class SelfopReusedRunScopeSqlTest { + Connection db; MappedStatement statement; BoundSql bound; + final Map params=Map.of("scope",7L,"account","SYNTH-ACCOUNT","warehouse","SYNTH-A", + "hash","scope-hash","published",900L,"now",LocalDateTime.of(2026,10,14,0,0)); + + @BeforeEach void setup() throws Exception { + db=DriverManager.getConnection("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE","sa",""); + execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,scope_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,scope_hash VARCHAR,result_published_run_id BIGINT,attempt_count INT,execution_purpose VARCHAR,progress_json VARCHAR)"); + execute("CREATE TABLE ll_selfop_sync_run(id BIGINT PRIMARY KEY,task_id BIGINT,scope_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,scope_hash VARCHAR,attempt_no INT,execution_purpose VARCHAR,status VARCHAR,finished_at_utc TIMESTAMP,updated_at TIMESTAMP,progress_snapshot_json VARCHAR)"); + execute("CREATE ALIAS JSON_EXTRACT FOR '"+getClass().getName()+".jsonExtract'"); + var config=new Configuration();config.addMapper(SelfopCoordinationMapper.class); + statement=config.getMappedStatement(SelfopCoordinationMapper.class.getName()+".completeReusedRuns"); + bound=statement.getBoundSql(params); + } + @AfterEach void close() throws Exception {db.close();} + public static String jsonExtract(String body,String path) throws Exception { + if(body==null)return null;assertEquals("$.attemptNo",path); + var value=new ObjectMapper().readTree(body).get("attemptNo");return value==null?null:value.asText(); + } + void execute(String sql,Object...args) throws Exception { + try(var p=db.prepareStatement(sql)){for(int n=0;n row(long id) throws Exception { + try(var p=db.prepareStatement("SELECT * FROM ll_selfop_sync_run WHERE id=?")){p.setLong(1,id);try(var r=p.executeQuery()){assertTrue(r.next());var out=new TreeMap();for(int n=1;n<=r.getMetaData().getColumnCount();n++)out.put(r.getMetaData().getColumnLabel(n),r.getObject(n));return out;}} + } + int update() throws Exception { + String sql=bound.getSql().replaceAll("\\s+"," ").trim(); + int set=sql.indexOf(" SET "),where=sql.indexOf(" WHERE ");assertTrue(sql.startsWith("UPDATE ")&&set>0&&where>set); + String from=sql.substring(7,set),predicate=sql.substring(where+7); + // Assignment order preserves MyBatis parameter order. No test-owned selection predicate. + var projection=new ArrayList();var assignments=new ArrayList(); + for(String assignment:sql.substring(set+5,where).split(",(?=r\\.)")){ + int equal=assignment.indexOf('=');String column=assignment.substring(0,equal).replace("r.",""); + String expression=assignment.substring(equal+1); + // H2 cannot infer a bare parameter's type in a derived table; production columns are timestamps. + if(expression.equals("?")){assertTrue(column.equals("finished_at_utc")||column.equals("updated_at"));expression="CAST(? AS TIMESTAMP)";} + projection.add(expression+" AS "+column);assignments.add("destination."+column+"=source."+column); + } + String adapted="MERGE INTO ll_selfop_sync_run destination USING (SELECT r.id,"+String.join(",",projection)+" FROM "+from+" WHERE "+predicate+") source ON destination.id=source.id WHEN MATCHED THEN UPDATE SET "+String.join(",",assignments); + try(var p=db.prepareStatement(adapted)){new DefaultParameterHandler(statement,params,bound).setParameters(p);return p.executeUpdate();} + } + @Test void nonemptyMatchingAttemptsCompleteAllFourEligibleStatusesAndKeepProgress() throws Exception { + String[] statuses={"FETCHING","FACTS_APPLIED","BUILDING","READY"}; + for(int n=0;n execute("UPDATE ll_selfop_sync_run SET attempt_no=1 WHERE id=12"); + case "wrong-result" -> execute("UPDATE ll_selfop_sync_task SET result_published_run_id=901 WHERE id=1012"); + case "run-validation" -> execute("UPDATE ll_selfop_sync_run SET execution_purpose='VALIDATION' WHERE id=12"); + case "task-validation" -> execute("UPDATE ll_selfop_sync_task SET execution_purpose='VALIDATION' WHERE id=1012"); + case "null-scope" -> execute("UPDATE ll_selfop_sync_task SET scope_id=NULL WHERE id=1012"); + default -> execute("UPDATE ll_selfop_sync_run SET status=? WHERE id=12",fault); + } + var before=row(12);assertEquals(1,update());assertEquals("REUSED",row(11).get("status"));assertEquals(before,row(12)); + } + @Test void mismatchedProgressAttemptIsNotCopiedIntoCompletedRun() throws Exception { + pair(11,"READY");execute("UPDATE ll_selfop_sync_task SET progress_json=? WHERE id=1011","{\"attemptNo\":1}"); + assertEquals(1,update());assertEquals("REUSED",row(11).get("status"));assertNull(row(11).get("progress_snapshot_json")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java index 9364a17bc..35b370ead 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java @@ -122,6 +122,9 @@ class SelfopSalesPublisherTest { ordered.verify(runs).update(isNull(),any()); ordered.verify(scopes).updateById(any(SelfopSyncScope.class)); ordered.verify(runs).updateById(any(SelfopSyncRun.class)); ordered.verify(tasks).updateById(any(SelfopSyncTask.class)); + ordered.verify(locks).completeCoveredTasks(eq(1L),eq(scope.getScopeHash()),eq(10L),eq(run.getCoveredFromUtc()),eq(run.getCoveredUntilUtc()),any()); + ordered.verify(locks).completeReusedRuns(eq(1L),eq(scope.getSourceAccount()),eq(scope.getWarehouseCode()),eq(scope.getScopeHash()),eq(10L),any()); + ordered.verify(locks).completeCoveredMarkers(eq(1L),eq(scope.getScopeHash()),eq(10L),eq(run.getCoveredUntilUtc()),any()); assertNotNull(SelfopSalesPublisher.class.getMethod("publish",long.class,long.class,String.class,long.class).getAnnotation(Transactional.class)); assertEquals(10L,task.getResultPublishedRunId());assertNotNull(run.getProgressSnapshotJson()); // Publisher constructor has no inventory repositories or inventory publication prerequisite. From 674914c65194e259d4cf697d49afb8ca3c6690e6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 09:46:49 +0800 Subject: [PATCH 70/80] fix(selfop): allow explicit retry of failed continuous inventory roots --- .../analytics/sync/SelfopTaskService.java | 26 +++++++++---- .../analytics/sync/SelfopTaskServiceTest.java | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java index 0a9986582..111b0fbb5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -224,18 +224,23 @@ public class SelfopTaskService { lockBusinessAccount(id); var commandQuery=new QueryWrapper().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey); var prior=tasks.selectOne(commandQuery); - if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(preview);return preview;} + if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");businessGuard(preview,false);return preview;} // Match executor lock ordering: account/warehouse scope first, task second. String activeOwner;LocalDateTime activeUntil; if("ACCOUNT_DISCOVERY".equals(preview.getTaskRole())){var scope=locks.lockDiscovery(preview.getDiscoveryScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} else{var scope=locks.lockScope(preview.getScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} prior=tasks.selectOne(new QueryWrapper().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey).last("FOR UPDATE")); if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return tasks.selectById(id);} - businessGuard(preview); + businessGuard(preview,false,true); require(SelfopLeaseService.available(activeOwner,activeUntil,utcNow()),"SELFOP_RETRY_LEASE_BUSY"); var original=locks.lockTask(id);require(original!=null,"SELFOP_TASK_MISSING"); requireBusiness(original);requireExecution(config,original.getExecutionPurpose()); require(!"SUCCEEDED".equals(original.getStatus()),"SELFOP_TASK_ALREADY_SUCCEEDED"); + // The audit creation checks the parent again. Persist the authorized resume in this same + // transaction before creating it; any audit failure rolls the resume back as well. + original.setStatus("READY");original.setNextRetryAtUtc(utcNow()); + if(!continuousBusinessRoot(original))original.setRequestedBy(operator); + original.setUpdatedAt(utcNow());tasks.updateById(original); // Resume original logical task, retaining apply barrier and stage set. Record the manual command separately. var audit=new SelfopSyncTask();audit.setSourceAccount(original.getSourceAccount());audit.setTaskRole(original.getTaskRole());audit.setScopeId(original.getScopeId()); audit.setExecutionPurpose(original.getExecutionPurpose()); @@ -243,9 +248,6 @@ public class SelfopTaskService { audit.setRequestKey("retry:"+requestKey);audit.setRequestHash(hash(List.of(id,requestKey,reason)));audit.setTaskType("MANUAL_RETRY");audit.setScopeHash(original.getScopeHash()); audit.setQueryScopeJson(json(Map.of("resumesTaskNo",original.getTaskNo(),"reason",reason)));audit.setStatus("SUCCEEDED");audit.setApplyState("NOT_APPLICABLE");audit.setRequestedBy(operator); create(audit); - original.setStatus("READY");original.setNextRetryAtUtc(utcNow()); - if(!continuousBusinessRoot(original))original.setRequestedBy(operator); - original.setUpdatedAt(utcNow());tasks.updateById(original); return original; } public List due(int limit){return continuousBusiness(config)?locks.dueContinuous(utcNow(),limit,config.getSourceAccount()):locks.due(utcNow(),limit,config.getCollectionMode());} @@ -301,7 +303,8 @@ public class SelfopTaskService { var task=tasks.selectById(id);businessGuard(task); } private void businessGuard(SelfopSyncTask task){businessGuard(task,true);} - private void businessGuard(SelfopSyncTask task,boolean terminate){ + private void businessGuard(SelfopSyncTask task,boolean terminate){businessGuard(task,terminate,false);} + private void businessGuard(SelfopSyncTask task,boolean terminate,boolean inventoryRetry){ if(!testBusiness(task))return; require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED"); @@ -318,7 +321,7 @@ public class SelfopTaskService { var budget=read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);budget.validate(); require(!budget.continuous||(continuousBusiness(config)&&continuousBusinessRoot(root)),"SELFOP_CONTINUOUS_COLLECTION_DISABLED"); String reason=budget.stopReason; - if(reason==null&&"DEAD".equals(root.getStatus()))reason="BUSINESS_SESSION_STOPPED"; + if(reason==null&&"DEAD".equals(root.getStatus())&&!(inventoryRetry&&failedInventoryScanRoot(root)))reason="BUSINESS_SESSION_STOPPED"; if(reason==null)return; if(!terminate)throw new BusinessSessionStoppedException(reason); var now=utcNow();budget.stopReason=reason; @@ -331,6 +334,15 @@ public class SelfopTaskService { .set("status","DEAD").set("next_retry_at_utc",null).set("updated_at",now)); throw new BusinessSessionStoppedException(reason); } + private boolean failedInventoryScanRoot(SelfopSyncTask root){ + if(!continuousBusiness(config)||!continuousBusinessRoot(root)||!"WAREHOUSE_SYNC".equals(root.getTaskRole()))return false; + var scope=scopes.selectById(root.getScopeId()); + if(scope==null||!"INVENTORY".equals(scope.getDataFamily()))return false; + var latest=runs.selectOne(new QueryWrapper().eq("task_id",root.getId()) + .orderByDesc("attempt_no","id").last("LIMIT 1")); + return latest!=null&&"FAILED".equals(latest.getStatus()) + &&Set.of("SELFOP_STOCK_DUPLICATE","SELFOP_STOCK_COUNT_MISMATCH","SELFOP_PAGE_TOTAL_CHANGED").contains(latest.getErrorCode()); + } private static void checkTaskLease(SelfopSyncTask task,SelfopLeaseService.Lease lease){ require(task!=null&&(lease.account()?"ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&Objects.equals(task.getDiscoveryScopeId(),lease.scopeId()) :"WAREHOUSE_SYNC".equals(task.getTaskRole())&&Objects.equals(task.getScopeId(),lease.scopeId())),"SELFOP_TASK_LEASE_SCOPE_MISMATCH"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java index f1d89dfb2..1814fb3db 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java @@ -506,6 +506,43 @@ class SelfopTaskServiceTest { service.fail(1L,20L,"SELFOP_STOCK_DUPLICATE",false,lease); assertThat(root.getStatus()).isEqualTo("DEAD"); } + @Test void commandRetriesDeadContinuousInventoryThroughRealTaskServiceAndRecordsAudit(){ + var root=continuousInventoryRoot();root.setStatus("DEAD");root.setAttemptCount(2); + var failed=run("FAILED");failed.setErrorCode("SELFOP_STOCK_DUPLICATE"); + when(runs.selectOne(any())).thenReturn(failed); + when(tasks.selectOne(any())).thenAnswer(inv->{var q=(QueryWrapper)inv.getArgument(0); + return q.getSqlSegment().contains("task_no")?root:null;}); + config.setSourceAccount("synthetic-account");config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + String budget=root.getBusinessBudgetJson();var planned=root.getPlannedAtUtc(); + var commands=new SelfopCommandService(service,scopes,accounts,config,tasks); + var command=new SelfopCommandService.Command("recover-stock","WH-A","RETRY",root.getTaskNo(),null,null,null,null,"retry completed unstable inventory scan"); + assertThat(commands.request(command,"operator")).isSameAs(root); + assertThat(root.getStatus()).isEqualTo("READY");assertThat(root.getAttemptCount()).isEqualTo(2); + assertThat(root.getBusinessBudgetJson()).isEqualTo(budget);assertThat(root.getPlannedAtUtc()).isEqualTo(planned); + assertThat(root.getRequestedBy()).isEqualTo(SelfopCollectionPolicy.CONTINUOUS_PLANNER); + var order=inOrder(tasks);order.verify(tasks).updateById(root); + order.verify(tasks).insert(argThat((SelfopSyncTask audit)->"MANUAL_RETRY".equals(audit.getTaskType()) + &&"SUCCEEDED".equals(audit.getStatus())&&Long.valueOf(1).equals(audit.getUpstreamTaskId()) + &&Long.valueOf(1).equals(audit.getBusinessRootTaskId()))); + } + @Test void explicitInventoryRetryCannotClearPersistedStopOrRetryUnknownFailure(){ + for(String error:List.of("LINGXING_WMS_RATE_LIMITED","INTEGRATION_TRANSPORT_FAILURE","SELFOP_EXECUTION_FAILED")){ + var root=continuousInventoryRoot();root.setStatus("DEAD");String budget=root.getBusinessBudgetJson(); + var failed=run("FAILED");failed.setErrorCode(error);when(runs.selectOne(any())).thenReturn(failed); + assertThatThrownBy(()->service.retry(1L,"retry-protected","operator","offline retry")) + .isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); + assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getBusinessBudgetJson()).isEqualTo(budget); + } + var root=continuousInventoryRoot();root.setStatus("DEAD"); + var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); + budget.stopReason="BUSINESS_SESSION_STOPPED";root.setBusinessBudgetJson(json(budget));String frozen=root.getBusinessBudgetJson(); + var failed=run("FAILED");failed.setErrorCode("SELFOP_STOCK_DUPLICATE");when(runs.selectOne(any())).thenReturn(failed); + assertThatThrownBy(()->service.retry(1L,"retry-stopped","operator","offline retry")) + .isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); + assertThat(root.getBusinessBudgetJson()).isEqualTo(frozen); + verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(tasks,never()).insert(any(SelfopSyncTask.class)); + verify(tasks,never()).update(any(),any()); + } private SelfopSyncTask continuousInventoryRoot(){ var root=boundedRoot(0,utcNow().plusMinutes(3));config.setContinuousBusinessCollectionEnabled(true); root.setTaskType("SCHEDULED");root.setStatus("RUNNING");root.setApplyState("NOT_APPLICABLE"); From 61930438808e2cf407a39905b7f20f34ecaf2f7a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 10:09:16 +0800 Subject: [PATCH 71/80] perf(selfop): batch list checkpoints and publish each completed day --- .../outbound/OutboundCandidateBuilder.java | 4 -- .../sync/SelfopDiscoveryService.java | 52 ++++++++++++++++++- .../sync/SelfopExecutionService.java | 4 -- .../analytics/sync/SelfopLeaseService.java | 8 ++- .../OutboundCandidateBuilderTest.java | 7 --- .../sync/SelfopDiscoveryServiceTest.java | 48 +++++++++++++++++ .../sync/SelfopExecutionServiceTest.java | 15 ++++++ .../SelfopObservationReservationTest.java | 22 ++++++++ 8 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopObservationReservationTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java index cb3fd7b08..d11a7035e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java @@ -17,10 +17,6 @@ public class OutboundCandidateBuilder { if("FINISH".equals(checkpoint.phase()))transaction.finish(scopeId,runId,owner,fence); } public Long reusable(Long scopeId,Long runId,String owner,long fence) {return transaction.reusable(scopeId,runId,owner,fence);} - public static boolean regularBuildDue(SelfopSyncTask task,LocalDateTime previousBuild,LocalDateTime now) { - return "REBUILD".equals(task.getTaskType()) || "RECHECK".equals(task.getTaskType()) || task.getAttemptCount()!=null && task.getAttemptCount()>1 - || previousBuild==null || !now.isBefore(previousBuild.plusMinutes(30)); - } public static Coverage contiguous(LocalDateTime initialFrom,List intervals) { if(initialFrom==null)return new Coverage(null,null);LocalDateTime until=initialFrom; for(Coverage interval:intervals.stream().filter(i->i.from()!=null && i.until()!=null && i.from().isBefore(i.until())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java index 01d40237f..5a65e13d3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -93,8 +93,12 @@ public class SelfopDiscoveryService { } } route(task,run,manifest,lease,false); - for(var entry:manifest.getObjects().entrySet()) { + var entries=new ArrayList<>(manifest.getObjects().entrySet()); + for(int index=0;index0){index+=accepted-1;continue;} + var entry=entries.get(index); var ref=entry.getValue(); if(ref.getRefreshTaskId()!=null){ var refresh=tasks.selectById(ref.getRefreshTaskId());require(refresh!=null,"SELFOP_REFRESH_TASK_MISSING"); @@ -211,6 +215,52 @@ public class SelfopDiscoveryService { .set("catalog_from_utc",account.getCatalogFromUtc()).set("catalog_until_utc",account.getCatalogUntilUtc()).set("updated_at",now));} }); } + /** Complete list rows need one bounded transaction/checkpoint per batch, not per order. + * Missing data, resumed references and newer revisions still follow the individual resolution path. + * Stages and their manifest references commit together; no network call occurs in this transaction. */ + private int acceptListBatch(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease, + SelfopRoutingManifest manifest,List> entries,int start) { + var candidates=new ArrayList>(); + for(int i=start;i(); + stages.selectList(new QueryWrapper().in("id",candidates.stream().map(e->e.getValue().getDirectoryStageId()).toList())) + .forEach(s->directory.put(s.getId(),s)); + var revised=new HashSet(); + stages.selectList(new QueryWrapper().select("object_key_hash").eq("object_type","OUTBOUND_ORDER") + .in("object_key_hash",candidates.stream().map(Map.Entry::getKey).toList()).eq("validation_status","VALID") + .gt("observation_sequence",task.getObservationBaseline())).forEach(s->revised.add(s.getObjectKeyHash())); + var payloads=new ArrayList(); + for(var e:candidates) { + var s=directory.get(e.getValue().getDirectoryStageId()); + if(s==null||!Objects.equals(s.getStageBatchId(),task.getId())||!"PENDING".equals(s.getValidationStatus())||revised.contains(e.getKey()))break; + var p=read(s.getPayloadJson(),SelfopPayloads.Order.class); + if(p==null||LingxingAnalyticsClient.needsDetail(p))break; + require(Objects.equals(p.sourceAccount(),task.getSourceAccount())&&Objects.equals(p.customerCode(),e.getValue().getCustomerCode()) + &&Objects.equals(p.deliveryNo(),e.getValue().getDeliveryNo()),"SELFOP_DETAIL_IDENTITY_MISMATCH"); + require(p.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID");identity(p.warehouseCode(),64); + payloads.add(p); + } + if(payloads.isEmpty())return 0; + leases.renew(lease); + tx.executeWithoutResult(s->{ + var checkpoint=taskService.checkpoint(task,run,lease); + long first=leases.nextObservations(lease,payloads.size()); + for(int i=0;i().eq("scope_id",task.getScopeId()).isNotNull("build_started_at_utc").orderByDesc("build_started_at_utc").last("LIMIT 1")); - if(previousBuild!=null&&!OutboundCandidateBuilder.regularBuildDue(task,previousBuild.getBuildStartedAtUtc(),utcNow())){ - taskService.waitForBudget(taskId,run.getId(),previousBuild.getBuildStartedAtUtc().plusMinutes(30),primary);return; - } candidates.build(task.getScopeId(),run.getId(),owner,primary.fence()); if(config.isSalesPublicationEnabled())publisher.publish(task.getScopeId(),run.getId(),owner,primary.fence()); else taskService.candidateFinished(taskId,run.getId(),primary); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java index e019daf45..775e612a2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java @@ -66,9 +66,15 @@ public class SelfopLeaseService { } @Transactional public long nextObservation(Lease lease) { + return nextObservations(lease,1); + } + /** Reserve consecutive observations under the same fence; gaps after a failed batch are harmless. */ + @Transactional + public long nextObservations(Lease lease,int count) { + require(count>0&&count<=100,"SELFOP_OBSERVATION_BATCH_INVALID"); require(lease.account(),"SELFOP_ACCOUNT_LEASE_REQUIRED"); var s=locks.lockDiscovery(lease.scopeId()); check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow()); - long seq=s.getNextObservationSequence(); s.setNextObservationSequence(seq+1); s.setUpdatedAt(utcNow()); + long seq=s.getNextObservationSequence(); s.setNextObservationSequence(Math.addExact(seq,count)); s.setUpdatedAt(utcNow()); discoveries.updateById(s); return seq; } public void verify(Lease lease) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java index d62d326a3..b1003ff90 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java @@ -173,13 +173,6 @@ class OutboundCandidateBuilderTest { assertNull(OutboundCandidateBuilder.class.getMethod("build",Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class)); for(String name:List.of("begin","batch","finish"))assertNotNull(OutboundCandidateTransaction.class.getMethod(name,Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class)); } - @Test void onlyRoutineFirstCollectionIsCoalesced() { - task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(1); LocalDateTime now=SelfopSupport.utcNow(); - assertFalse(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(29),now)); - assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(30),now)); - task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); - task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); - } @Test void missingWarehouseSummaryPreventsReadyAndOldReadyCannotBypassBuild() { transaction.begin(1L,10L,"worker",2); var row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("sku");row.setBusinessDate(LocalDate.of(2026,10,1)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java index 18df3af68..52115e32c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java @@ -378,6 +378,54 @@ class SelfopDiscoveryServiceTest { assertEquals("ROUTED",run.getStatus()); assertEquals(1,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getListPayloadVersion()); } + private void prepareCompleteBatch(int count) { + var manifest=new SelfopRoutingManifest();manifest.setListPayloadVersion(1);manifest.setDirectoryComplete(true);manifest.setPages(1);manifest.setTotal((long)count); + for(int i=0;i{ + QueryWrapper q=inv.getArgument(0);String sql=q.getSqlSegment();var values=q.getParamNameValuePairs().values(); + if(sql.contains("observation_sequence"))return List.of(); + return stored.values().stream().filter(s->values.contains(s.getId())).toList(); + }); + long[] next={101};when(leases.nextObservations(eq(lease),anyInt())).thenAnswer(inv->{long first=next[0];next[0]+=inv.getArgument(1);return first;}); + } + @Test void completeListsCommitBoundedBatchesInsteadOfPerOrderManifests() { + prepareCompleteBatch(205);service.collect(task,run,lease); + verifyNoInteractions(client);verify(leases,times(2)).nextObservations(lease,100);verify(leases).nextObservations(lease,5); + verify(tasks,times(4)).updateById(argThat((SelfopSyncTask t)->task.getId().equals(t.getId()))); + assertEquals(205,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count()); + assertEquals(205,stored.values().stream().map(SelfopSyncStage::getObservationSequence).distinct().count()); + assertEquals(101L,stored.values().stream().mapToLong(SelfopSyncStage::getObservationSequence).min().orElseThrow()); + assertEquals(305L,stored.values().stream().mapToLong(SelfopSyncStage::getObservationSequence).max().orElseThrow()); + assertEquals("ROUTED",run.getStatus());assertTrue(children.values().stream().allMatch(c->"READY".equals(c.getStatus()))); + } + @Test void batchYieldResumesOnlyUncommittedRowsWithoutSupplierRequests() { + prepareCompleteBatch(205); + try(var budgets=mockConstruction(SelfopBudget.class,(b,c)->when(b.hasTime()).thenReturn(true,false))){service.collect(task,run,lease);} + assertEquals(100,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count()); + assertTrue(children.values().stream().allMatch(c->"WAITING_SOURCE".equals(c.getStatus()))); + service.collect(task,run,lease);verifyNoInteractions(client); + verify(stages,times(205)).updateById(any(SelfopSyncStage.class));assertEquals("ROUTED",run.getStatus()); + } + @Test void stoppedBatchCannotPromoteRowsOrAllocateObservations() { + prepareCompleteBatch(100);int[] checkpoints={0}; + when(taskService.checkpoint(task,run,lease)).thenAnswer(inv->{if(++checkpoints[0]>1)throw new SelfopTaskService.BusinessSessionStoppedException("STOPPED");return new SelfopTaskService.CollectionCheckpoint(task,run);}); + assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->service.collect(task,run,lease)); + verify(leases,never()).nextObservations(any(),anyInt());verify(stages,never()).updateById(any(SelfopSyncStage.class)); + assertTrue(stored.values().stream().allMatch(s->"PENDING".equals(s.getValidationStatus())));verifyNoInteractions(client); + } + @Test void incompleteRowInBatchStillUsesDetailAndPreservesCompleteRows() { + prepareCompleteBatch(3);var missing=stored.get(1001L);var p=read(missing.getPayloadJson(),SelfopPayloads.Order.class); + missing.setPayloadJson(json(new SelfopPayloads.Order(p.sourceAccount(),p.customerCode(),p.warehouseCode(),p.deliveryNo(),p.sourceNo(),p.sourceStatus(),p.sourceCreatedTime(),p.sourceOutboundTime(),null))); + service.collect(task,run,lease);verify(client).detail(any(),eq("A"),eq("customer"),eq("batch-1")); + verify(client,never()).detail(any(),eq("A"),eq("customer"),eq("batch-0"));verify(client,never()).detail(any(),eq("A"),eq("customer"),eq("batch-2")); + assertTrue(stored.values().stream().allMatch(s->"VALID".equals(s.getValidationStatus()))); + } @Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() { task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION"); assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java index 9a0914a9c..48f6ddcaa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java @@ -10,6 +10,21 @@ import java.time.LocalDateTime; import static org.mockito.Mockito.*; class SelfopExecutionServiceTest { + @Test void completedDayBuildsAndPublishesImmediatelyWithoutBuildIntervalWait() { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production");config.setSalesPublicationEnabled(true); + var taskService=mock(SelfopTaskService.class);var leases=mock(SelfopLeaseService.class);var tasks=mock(SelfopSyncTaskMapper.class);var scopes=mock(SelfopSyncScopeMapper.class); + var task=new SelfopSyncTask();task.setId(1L);task.setExecutionPurpose("BUSINESS");task.setStatus("READY");task.setTaskRole("WAREHOUSE_SYNC");task.setTaskType("BACKFILL");task.setScopeId(3L);task.setWarehouseCode("A");task.setApplyState("APPLIED");task.setAttemptCount(1);task.setRoutingManifestJson("{\"objects\":{}}"); + var scope=new SelfopSyncScope();scope.setId(3L);scope.setWarehouseCode("A");scope.setDataFamily("OUTBOUND");var run=new SelfopSyncRun();run.setId(2L); + var lease=new SelfopLeaseService.Lease(3L,"worker",8L,false); + when(tasks.selectById(1L)).thenReturn(task);when(scopes.selectById(3L)).thenReturn(scope);when(scopes.selectOne(any())).thenReturn(scope); + when(leases.acquireScopes(any(),anyString())).thenReturn(java.util.Map.of(3L,lease));when(taskService.begin(1L,lease)).thenReturn(run); + var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class);var runs=mock(SelfopSyncRunMapper.class); + when(candidates.reusable(anyLong(),anyLong(),anyString(),anyLong())).thenReturn(null); + var service=new SelfopExecutionService(config,taskService,leases,mock(SelfopDiscoveryService.class),mock(SelfopInventoryCollector.class),mock(InventorySnapshotService.class),mock(OutboundFactService.class),candidates,publisher,scopes,tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),mock(SelfopReferenceRecovery.class),mock(SelfopValidationRunner.class),runs); + service.execute(1L); + var order=inOrder(candidates,publisher);order.verify(candidates).build(eq(3L),eq(2L),anyString(),eq(8L));order.verify(publisher).publish(eq(3L),eq(2L),anyString(),eq(8L)); + verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any());verifyNoInteractions(runs); + } @Test void unexpectedValidationFailureTerminatesSessionInsteadOfSchedulingRetry() { executeValidationFailure(new IllegalStateException("VALIDATION_STRUCTURE_REJECTED"),"VALIDATION_STRUCTURE_REJECTED"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopObservationReservationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopObservationReservationTest.java new file mode 100644 index 000000000..090860e78 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopObservationReservationTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.utcNow; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SelfopObservationReservationTest { + @Test void consecutiveBatchReservationsRetainFenceAndSingleObservationSemantics() { + var locks=mock(SelfopCoordinationMapper.class);var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class); + var row=new SelfopOutboundDiscoveryScope();row.setId(1L);row.setLeaseOwner("worker");row.setFenceToken(2L); + row.setLeaseUntilUtc(utcNow().plusMinutes(1));row.setNextObservationSequence(101L);when(locks.lockDiscovery(1L)).thenReturn(row); + var service=new SelfopLeaseService(locks,mock(SelfopSyncScopeMapper.class),accounts,new SelfopAnalyticsProperties()); + var lease=new SelfopLeaseService.Lease(1L,"worker",2L,true); + assertEquals(101L,service.nextObservations(lease,100));assertEquals(201L,service.nextObservation(lease));assertEquals(202L,row.getNextObservationSequence()); + assertThrows(IllegalStateException.class,()->service.nextObservations(lease,0));assertThrows(IllegalStateException.class,()->service.nextObservations(lease,101)); + assertThrows(IllegalStateException.class,()->service.nextObservations(new SelfopLeaseService.Lease(1L,"old-worker",1L,true),10)); + assertEquals(202L,row.getNextObservationSequence());verify(accounts,times(2)).updateById(row); + } +} From 83dc6dea3f4b4e5af60a15302096c07aa041ee7a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 11:03:02 +0800 Subject: [PATCH 72/80] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../PlatformAnalyticsController.java | 2 +- .../mapper/SelfopCoordinationMapper.java | 4 +- .../query/AnalyticsInventoryOptions.java | 6 +- .../query/AnalyticsInventorySql.java | 20 ++--- .../query/AnalyticsQueryService.java | 5 +- .../query/CoverageDistributionPolicy.java | 2 +- .../query/CoverageDistributionSql.java | 14 ++-- .../query/DashboardQueryService.java | 23 +++--- .../analytics/query/DashboardReadSql.java | 4 +- .../analytics/query/DashboardResponses.java | 7 +- .../query/InventoryTurnoverQueryService.java | 2 +- .../query/InventoryTurnoverRequest.java | 7 +- .../analytics/query/InventoryTurnoverSql.java | 30 ++++++-- .../query/InventoryWindowPolicy.java | 8 +- .../openapi/self-operated-analytics-v1.yaml | 30 ++++---- .../self-operated-inventory-turnover-v1.yaml | 4 +- .../query/AnalyticsQueryServiceTest.java | 18 ++++- .../query/AnalyticsSqlSemanticTest.java | 6 +- .../analytics/query/DashboardPolicyTest.java | 5 +- .../query/DashboardQueryServiceTest.java | 17 +++-- .../query/DashboardSqlSemanticTest.java | 38 +++++++++- .../query/InventoryTurnoverContextTest.java | 23 ++++++ .../query/InventoryTurnoverSqlTest.java | 59 ++++++++++++++ .../query/InventoryWindowPolicyTest.java | 11 ++- .../sync/SelfopContinuousDueSqlTest.java | 76 +++++++++++++++++++ 26 files changed, 325 insertions(+), 98 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java diff --git a/AGENTS.md b/AGENTS.md index c4a00bd25..3fddbd721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | -| 自营领星采集、经营统计、SKU 日销量 | `integration-service/` 采集适配、`lastleg-service/selfoperated/` 查询;按实际链路只读相关模块 | +| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 | | 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` | | 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 | | PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java index 61ea808c1..d03f9f64a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java @@ -52,7 +52,7 @@ public class PlatformAnalyticsController { requirePlatform(user);return ApiResponse.success(service.slowSkus(query)); } @GetMapping("/inventory-coverage") - @Operation(summary="正品库存、10或30完整日日均和预计可售天数") + @Operation(summary="正品库存与所选窗口日均和预计可售天数,支持总览原图区间下钻") public ApiResponse> inventoryCoverage( @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){ requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java index b6465397d..b324ee8e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java @@ -17,7 +17,9 @@ public interface SelfopCoordinationMapper { SelfopSyncRun lockRun(long id); @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") List due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); - @Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR))) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") + // Started inventory work may resume across midnight; unstarted historical slots remain excluded. + // Publication independently requires the planned day and observation day to match for daily samples. + @Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") List dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account); @Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") long barriers(@Param("account") String account, @Param("warehouse") String warehouse); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java index ba76bb012..1d2f068d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java @@ -13,12 +13,14 @@ public record AnalyticsInventoryOptions(String keyword,String attention,String s public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";} public void validate(String endpoint,AnalyticsQuery q){ CoverageDistributionPolicy.validate(this); + if("DASHBOARD".equals(drilldownFrom)&&(salesWindowDays!=null||salesWindowEndDate!=null||!"CUSTOM".equals(q.rangePreset())||q.startDate()==null||q.endDate()==null)) + throw new BizException("总览库存下钻须携带原图起止日期,不能混用库存窗口"); if(salesDays()!=10&&salesDays()!=30)throw new BizException("库存销量窗口仅支持10或30天"); if(!"INVENTORY".equals(endpoint)&&(salesWindowDays!=null||salesWindowEndDate!=null))throw new BizException("销量窗口仅用于库存明细"); if(!"INVENTORY".equals(endpoint)&&(coverageBucket!=null||coverageSelection!=null||drilldownFrom!=null))throw new BizException("分布下钻条件仅用于库存明细"); if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数"); - if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效"); - if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效"); + if(!Set.of("ALL","NO_OUTBOUND_IN_WINDOW","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效"); + if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_ASC").contains(sortValue()))throw new BizException("库存排序无效"); if("COVERAGE_GT_THRESHOLD".equals(attentionValue())&&coverageDaysThreshold==null)throw new BizException("超过可售天数筛选必须指定阈值"); if("OWNERS".equals(endpoint)){ if(keyword!=null&&(keyword.isBlank()||keyword.length()>64))throw new BizException("货主编码前缀不能为空或超长"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java index a96458476..a7877d253 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -20,8 +20,8 @@ public final class AnalyticsInventorySql { static String dashboardCte(AnalyticsSqlRequest p){return cte(p,false,true);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst){return cte(p,stockFirst,false);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst,boolean dashboard){ - String days=("INVENTORY".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30"; - String complete=("INVENTORY".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?"#{p.completeWindow}":"#{p.complete30}"; + String days=("INVENTORY".equals(p.kind())||"DASHBOARD".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30"; + String complete=("INVENTORY".equals(p.kind())||"DASHBOARD".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?"#{p.completeWindow}":"#{p.complete30}"; return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," @@ -36,7 +36,7 @@ public final class AnalyticsInventorySql { +"FROM ll_selfop_inventory_snapshot WHERE "+(dashboard&&!p.stockFresh()?"1=0 /* dashboard unused stock */ AND ":"") +"snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code)," +(stockFirst?stockPageKeys():"") - +salesCte(p,stockFirst,dashboard&&!"HOT_DASHBOARD".equals(p.kind())&&!p.complete30()) + +salesCte(p,stockFirst,dashboard&&!"HOT_DASHBOARD".equals(p.kind())&&!p.completeWindow()) +" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid," +"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc," +stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc," @@ -67,26 +67,26 @@ public final class AnalyticsInventorySql { +"ORDER BY "+amount+" IS NULL,"+amount+" DESC,c.sku_code ASC,c.customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}),";} private static boolean stockFirst(AnalyticsInventoryOptions o){return "ALL".equals(o.attentionValue())&&"AVAILABLE_DESC".equals(o.sortValue()) &&o.coverageBucket()==null&&o.coverageSelection()==null;} - private static String filter(AnalyticsInventoryOptions o){ - String distribution=CoverageDistributionSql.filter(o); + private static String filter(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + String distribution=CoverageDistributionSql.filter(p,o); if(!distribution.isEmpty())return " WHERE "+distribution; return switch(o.attentionValue()){ - case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1"; + case "NO_OUTBOUND_IN_WINDOW" -> " WHERE no_outbound=1"; case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1"; case "FROZEN_STOCK" -> " WHERE frozen_quantity>0"; case "DEFECTIVE_STOCK" -> " WHERE defective_physical_quantity>0"; case "UNASSESSABLE" -> " WHERE unassessable=1"; default -> "";};} private static String order(AnalyticsInventoryOptions o){String field=switch(o.sortValue()){ - case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_30D_ASC" -> "outbound_quantity30d";default -> "available_quantity";}; - return field+" IS NULL,"+field+("OUTBOUND_30D_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";} + case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_ASC" -> "outbound_quantity30d";default -> "available_quantity";}; + return field+" IS NULL,"+field+("OUTBOUND_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";} public static String inventoryPage(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); // The full candidate set is filtered and sorted first. Historical dates enrich only // those immutable keys selected for this page and never participate in qualification. boolean stockFirst=stockFirst(o); // ALL/available ordering depends only on stock; aggregate sales after selecting these keys. String paged=cte(p,stockFirst)+", paged AS (SELECT * FROM result" - +(stockFirst?"":filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}")+") " + +(stockFirst?"":filter(p,o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}")+") " +(stockFirst?"":", last_sales AS (SELECT d.customer_code,d.sku_code,MAX(d.business_date) last_outbound FROM ll_selfop_sku_daily_sales d " +"JOIN paged k ON k.customer_code=d.customer_code AND k.sku_code=d.sku_code WHERE d.sales_run_id=#{p.salesRun} " +"AND d.source_account=#{p.account} AND d.warehouse_code=#{p.warehouse} AND d.business_date<=#{p.anchor} " @@ -105,7 +105,7 @@ public final class AnalyticsInventorySql { public static String inventoryCount(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); if(o.coverageBucket()==null&&o.coverageSelection()==null&&java.util.Set.of("FROZEN_STOCK","DEFECTIVE_STOCK").contains(o.attentionValue())) return typed(stockAttentionCount(p,o)); - return typed("ALL".equals(o.attentionValue())&&o.coverageBucket()==null&&o.coverageSelection()==null?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));} + return typed("ALL".equals(o.attentionValue())&&o.coverageBucket()==null&&o.coverageSelection()==null?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(p,o));} /** These predicates depend only on stock. Counting them must not aggregate sales * or inspect the other profile version, even when those sales are unavailable. */ private static String stockAttentionCount(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java index af36ee5a3..306a38d7b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java @@ -179,7 +179,8 @@ public class AnalyticsQueryService { r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of()); } Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot,AnalyticsInventoryOptions inventoryOptions){ - q.validate(chart,exact,hot); Instant now=clock.instant(); + boolean dashboardWindow=inventoryOptions!=null&&"DASHBOARD".equals(inventoryOptions.drilldownFrom()); + q.validate(chart||dashboardWindow,exact,hot); Instant now=clock.instant(); List scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode()); Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY"); if(salesScope==null&&stockScope==null)throw new BizException("来源账号或仓库未配置"); @@ -204,7 +205,7 @@ public class AnalyticsQueryService { LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate(); SalesRangePolicy.Range range; try{ - range=inventoryOptions!=null?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||(hot&&"CUSTOM".equals(q.rangePreset())))?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), + range=inventoryOptions!=null&&!dashboardWindow?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||dashboardWindow||(hot&&"CUSTOM".equals(q.rangePreset())))?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays): new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30, anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java index 4f756c276..954331268 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java @@ -7,7 +7,7 @@ import java.util.List; /** V1's partition and strict read lifetime are a local report contract, never an authorization. */ public final class CoverageDistributionPolicy { public static final String RULE_VERSION="COVERAGE_DISTRIBUTION_V1"; - public static final List BUCKETS=List.of("DAYS_0_30","DAYS_30_60","DAYS_60_90","DAYS_GT_90","NO_OUTBOUND_30D","NO_AVAILABLE","UNKNOWN"); + public static final List BUCKETS=List.of("DAYS_0_30","DAYS_30_60","DAYS_60_90","DAYS_GT_90","NO_OUTBOUND_IN_WINDOW","NO_AVAILABLE","UNKNOWN"); private CoverageDistributionPolicy(){} public static void validate(AnalyticsInventoryOptions o){ if(o.coverageBucket()!=null&&(!BUCKETS.contains(o.coverageBucket())||!RULE_VERSION.equals(o.bucketRuleVersion())))throw new BizException("库存分布桶或规则版本无效"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java index 986a98174..19c04df8e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java @@ -5,17 +5,17 @@ public final class CoverageDistributionSql { private CoverageDistributionSql(){} public static String candidate(){return "(physical_quantity IS NULL OR physical_quantity>0)";} public static String finitePositive(){return "available_quantity>0 AND outbound_quantity30d>0";} - public static String bucket(){return "CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL THEN 'UNKNOWN' " + public static String bucket(AnalyticsSqlRequest p){long days=java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until());return "CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL THEN 'UNKNOWN' " +"WHEN physical_quantity=0 THEN NULL WHEN available_quantity=0 THEN 'NO_AVAILABLE' " - +"WHEN outbound_quantity30d IS NULL THEN 'UNKNOWN' WHEN outbound_quantity30d=0 THEN 'NO_OUTBOUND_30D' " - +"WHEN available_quantity*30<=outbound_quantity30d*30 THEN 'DAYS_0_30' " - +"WHEN available_quantity*30<=outbound_quantity30d*60 THEN 'DAYS_30_60' " - +"WHEN available_quantity*30<=outbound_quantity30d*90 THEN 'DAYS_60_90' ELSE 'DAYS_GT_90' END";} + +"WHEN outbound_quantity30d IS NULL THEN 'UNKNOWN' WHEN outbound_quantity30d=0 THEN 'NO_OUTBOUND_IN_WINDOW' " + +"WHEN available_quantity*"+days+"<=outbound_quantity30d*30 THEN 'DAYS_0_30' " + +"WHEN available_quantity*"+days+"<=outbound_quantity30d*60 THEN 'DAYS_30_60' " + +"WHEN available_quantity*"+days+"<=outbound_quantity30d*90 THEN 'DAYS_60_90' ELSE 'DAYS_GT_90' END";} public static String unknownReason(){return "CASE WHEN physical_quantity IS NULL THEN 'PHYSICAL_INVENTORY_UNKNOWN' WHEN available_quantity IS NULL THEN 'AVAILABLE_INVENTORY_UNKNOWN' " +"WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN owner_outbound IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' " +"WHEN observation_eligible=0 THEN 'OBSERVATION_INSUFFICIENT' ELSE 'METRIC_DEPENDENCY_UNKNOWN' END";} - public static String filter(AnalyticsInventoryOptions o){ - if(o.coverageBucket()!=null)return "#{p.inventoryRun}>0 AND "+candidate()+" AND ("+bucket()+")=#{o.coverageBucket}"; + public static String filter(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + if(o.coverageBucket()!=null)return "#{p.inventoryRun}>0 AND "+candidate()+" AND ("+bucket(p)+")=#{o.coverageBucket}"; if(o.coverageSelection()!=null)return finitePositive(); return ""; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java index 6bfeadc03..7786c7fd2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java @@ -20,19 +20,19 @@ public class DashboardQueryService { var snapshot=contextService.context(q,true,false,false,null);var c=snapshot.context(); if(snapshot.sales()!=null&&!Boolean.TRUE.equals(snapshot.sales().warehouseDailyReady())) throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询"); - var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate(); - var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom, - base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD",base.coverage()); + var base=contextService.request(snapshot,"DASHBOARD");LocalDate windowFrom=c.rangeStartDate(),windowUntil=c.rangeEndDateExclusive(); + int windowDays=(int)java.time.temporal.ChronoUnit.DAYS.between(windowFrom,windowUntil); + var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),windowFrom,windowUntil,base.anchor(),windowFrom, + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD",base.coverage()); var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null); var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive()); var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList(); Map stats=report.get(0);var ranked=report.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days,SalesQueryCoverage.intervals(snapshot.sales())); var trendSummary=SalesBucketPolicy.aggregate(trendPoints); - var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days,SalesQueryCoverage.intervals(snapshot.sales()));var fixed=SalesBucketPolicy.aggregate(fixedPoints); Map hotStats=stats;List hotRanked=ranked; - if(!c.rangeStartDate().equals(fixedFrom)||!c.rangeEndDateExclusive().equals(fixedUntil)||!base.complete30()){ - var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),fixedFrom, + if(!base.completeWindow()){ + var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),windowFrom, base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD",base.coverage()); var hotReport=mapper.report(hotRequest,o).stream().map(DashboardQueryService::normalize).toList(); hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); @@ -41,19 +41,19 @@ public class DashboardQueryService { trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays()); var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList(); var interval=new DashboardResponses.Metric(trendSummary.outboundQuantity(),trendSummary.observedQuantity(),"PCS",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),trendSummary.outboundQuantity(),trendSummary.observedQuantity()); - // A partial observed quantity is never divided by 30 and presented as an estimated daily average. - var average=new DashboardResponses.Metric(divide30(fixed.outboundQuantity()),divide30(fixed.outboundQuantity()),"PCS_PER_DAY",fixed.dataStatus(),null,fixed.reasonCodes(),null,null); + // Partial sales never become a formal daily average for the selected window. + var average=new DashboardResponses.Metric(divide(trendSummary.outboundQuantity(),windowDays),divide(trendSummary.outboundQuantity(),windowDays),"PCS_PER_DAY",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),null,null); boolean inventoryAvailable=base.inventoryRun()>0; var kpis=new DashboardResponses.Kpis(interval,average,stockMetric(stats,"physical_quantity_known","physical_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c), stockMetric(stats,"available_quantity_known","available_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c), stockMetric(stats,"physical_skus","physical_quantity_unknown","SKU",inventoryAvailable,base.stockFresh(),c), stockMetric(stats,"no_outbound_eligible","no_outbound_unknown","SKU",inventoryAvailable,base.stockFresh(),c)); Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil); - return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,fixedFrom,fixedUntil,30,freshUntil,drilldownUntil),kpis, + return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,freshUntil,drilldownUntil),kpis, new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c), hotRanking(ranking("HOT",hotStats,hotRanked,base.salesRun()>0,c.rangeStartDate(),c.rangeEndDateExclusive(),c),trendSummary.dataStatus()), - ranking("LONG",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c), - ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c)); + ranking("LONG",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c), + ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c)); } private static DashboardResponses.Ranking hotRanking(DashboardResponses.Ranking ranking,String windowStatus){ if("COMPLETE".equals(windowStatus))return ranking; @@ -111,6 +111,5 @@ public class DashboardQueryService { } private static BigDecimal decimal(Map source,String key){Object value=source.get(key);return value==null?null:value instanceof BigDecimal decimal?decimal:new BigDecimal(value.toString());} private static long count(Map source,String key){BigDecimal value=decimal(source,key);return value==null?0:value.longValueExact();} - private static BigDecimal divide30(BigDecimal value){return divide(value,30);} private static BigDecimal divide(BigDecimal value,int days){return value==null||days==0?null:value.divide(BigDecimal.valueOf(days),12,RoundingMode.HALF_UP);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java index f1634697a..980e406d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java @@ -12,7 +12,7 @@ public final class DashboardReadSql { +", hot_statistics AS (SELECT COUNT(CASE WHEN owner_outbound>0 THEN 1 END) hot_eligible,COUNT(CASE WHEN owner_outbound IS NULL THEN 1 END) hot_unknown FROM result)" +", hot_rank AS (SELECT * FROM result WHERE owner_outbound>0 ORDER BY owner_outbound DESC,order_count DESC,sku_code ASC,customer_code ASC LIMIT 10)" +", hot_rows AS ("+select("HOT","owner_outbound","hot_rank")+") SELECT s.*,r.* FROM hot_statistics s LEFT JOIN hot_rows r ON 1=1"); - // Only dashboard outputs are considered here. Without complete30 all sales-dependent + // Only dashboard outputs are considered here. Without completeWindow all sales-dependent // rankings are empty; observed sales still come from the independent daily query. // Stale stock quantities are already masked to NULL. Keep both profile versions so // candidate identities, observation evidence and unknown reasons remain unchanged. @@ -21,7 +21,7 @@ public final class DashboardReadSql { // share one calculation rather than expanding profile/stock/sales joins for each ranking. sql.append(", dashboard_result AS (SELECT DISTINCT * FROM result)"); sql.append(", classified AS (SELECT r.*,CASE WHEN ").append(CoverageDistributionSql.candidate()).append(" THEN ") - .append(CoverageDistributionSql.bucket()).append(" END bucket_code,").append(CoverageDistributionSql.unknownReason()).append(" unknown_reason FROM dashboard_result r), statistics AS (SELECT COUNT(*) total_skus"); + .append(CoverageDistributionSql.bucket(p)).append(" END bucket_code,").append(CoverageDistributionSql.unknownReason()).append(" unknown_reason FROM dashboard_result r), statistics AS (SELECT COUNT(*) total_skus"); for(String field:List.of("physical_quantity","available_quantity"))sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); sql.append(",COUNT(CASE WHEN physical_quantity>0 THEN 1 END) physical_skus,COUNT(CASE WHEN physical_quantity=0 THEN 1 END) excluded_zero_skus") .append(",COUNT(CASE WHEN bucket_code IS NOT NULL THEN 1 END) candidate_skus") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java index cc677886d..09f01b104 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java @@ -9,12 +9,11 @@ import java.util.Map; public final class DashboardResponses { private DashboardResponses(){} - public record Context(@JsonUnwrapped AnalyticsResponses.Context versionContext,LocalDate fixedWindowStartDate, - LocalDate fixedWindowEndDateExclusive,int fixedWindowDays,Instant inventoryFreshUntil,Instant inventoryDrilldownExpiresAt){} + public record Context(@JsonUnwrapped AnalyticsResponses.Context versionContext,Instant inventoryFreshUntil,Instant inventoryDrilldownExpiresAt){} public record Metric(BigDecimal value,BigDecimal knownSubtotal,String unit,String dataStatus,Long unknownSkuCount, List reasonCodes,BigDecimal outboundQuantity,BigDecimal observedQuantity){} - public record Kpis(Metric intervalOutboundQuantity,Metric averageDailyOutboundQuantity30d,Metric physicalQuantity, - Metric availableQuantity,Metric physicalSkuCount,Metric noOutboundSkuCount30d){} + public record Kpis(Metric intervalOutboundQuantity,Metric averageDailyOutboundQuantity,Metric physicalQuantity, + Metric availableQuantity,Metric physicalSkuCount,Metric noOutboundSkuCount){} public record SalesTrend(AnalyticsResponses.SalesSummary summary,List points){} public record Bucket(String bucketCode,Long skuCount,BigDecimal knownPhysicalQuantity,Long unknownPhysicalSkuCount){} public record Distribution(String bucketRuleVersion,Long candidateSkuCount,Long excludedZeroPhysicalSkuCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java index 5cbfe4ca8..a1140f22d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java @@ -38,7 +38,7 @@ public class InventoryTurnoverQueryService { } if(!hasTotal||request.overview()&&summary==null)throw new IllegalStateException("Missing turnover query section"); if(request.overview()) { - charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(input)),context.endDate().plusDays(1))); + charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1))); } return new Response(context,summary,charts,List.copyOf(rows),total); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java index a65cdf97a..e8fb8e892 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java @@ -38,8 +38,11 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0 || attention().startsWith("COVERAGE_")&&coverageDaysThreshold==null) throw new BizException("可售天数阈值须为正整数"); if(turnoverStatus!=null&&!"INSUFFICIENT".equals(turnoverStatus)) throw new BizException("周转状态无效"); - if(!Set.of("PHYSICAL_DESC","AVAILABLE_DESC","OUTBOUND_7_DESC","OUTBOUND_15_DESC","OUTBOUND_30_DESC", - "COVERAGE_ASC","COVERAGE_DESC","TURNOVER_DESC","NO_OUTBOUND_PHYSICAL_DESC").contains(sort()) + if(!Set.of("PHYSICAL_ASC","PHYSICAL_DESC","AVAILABLE_ASC","AVAILABLE_DESC", + "OUTBOUND_7_ASC","OUTBOUND_7_DESC","OUTBOUND_15_ASC","OUTBOUND_15_DESC","OUTBOUND_30_ASC","OUTBOUND_30_DESC", + "AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC","COVERAGE_ASC","COVERAGE_DESC", + "FROZEN_ASC","FROZEN_DESC","DEFECTIVE_ASC","DEFECTIVE_DESC","AVERAGE_PHYSICAL_ASC","AVERAGE_PHYSICAL_DESC", + "TURNOVER_ASC","TURNOVER_DESC","NO_OUTBOUND_PHYSICAL_DESC").contains(sort()) || "OWNER".equals(group())&&sort().startsWith("COVERAGE_") || "SKU".equals(group())&&"NO_OUTBOUND_PHYSICAL_DESC".equals(sort())) throw new BizException("排序不适用于当前分组"); if(page()<1||pageSize()<1||pageSize()>100) throw new BizException("分页范围为current≥1,size为1至100"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index 12d76479b..25eacdbbd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -19,8 +19,16 @@ public final class InventoryTurnoverSql { public LocalDateTime fromUtc(){return from().atStartOfDay().minusHours(8);} } static Input input(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen) { + return input(q,frozen,false); + } + static Input chartInput(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen) { + // Historical charts read only the frozen published sales version. Pending fact merges + // cannot erase its coverage; current stock assessments still use the stricter input. + return input(q,frozen,true); + } + private static Input input(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen,boolean publishedHistory) { var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales()); - boolean trusted=frozen.snapshot().sourceKnown()&&!frozen.response().analyticsContext().rebuildRequired(); + boolean trusted=frozen.snapshot().sourceKnown()&&(publishedHistory||!frozen.response().analyticsContext().rebuildRequired()); var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8), !trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList(); return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(), @@ -202,12 +210,22 @@ public final class InventoryTurnoverSql { } private static String order(Input p,boolean owner) { String field=switch(p.query.sort()) { - case "AVAILABLE_DESC" -> "available";case "OUTBOUND_7_DESC" -> "w7_outbound"; - case "OUTBOUND_15_DESC" -> "w15_outbound";case "OUTBOUND_30_DESC" -> "w30_outbound"; - case "COVERAGE_ASC","COVERAGE_DESC" -> "coverage_days";case "TURNOVER_DESC" -> "turnover"; - case "NO_OUTBOUND_PHYSICAL_DESC" -> "no_outbound_physical";default -> "physical"; + case "PHYSICAL_ASC","PHYSICAL_DESC" -> "physical"; + case "AVAILABLE_ASC","AVAILABLE_DESC" -> "available"; + case "OUTBOUND_7_ASC","OUTBOUND_7_DESC" -> "w7_outbound"; + case "OUTBOUND_15_ASC","OUTBOUND_15_DESC" -> "w15_outbound"; + case "OUTBOUND_30_ASC","OUTBOUND_30_DESC" -> "w30_outbound"; + // The denominator is constant for the whole query; use the unrounded numerator. + case "AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC" -> "w"+p.query.days()+"_outbound"; + case "COVERAGE_ASC","COVERAGE_DESC" -> "coverage_days"; + case "FROZEN_ASC","FROZEN_DESC" -> "frozen"; + case "DEFECTIVE_ASC","DEFECTIVE_DESC" -> "defective"; + case "AVERAGE_PHYSICAL_ASC","AVERAGE_PHYSICAL_DESC" -> "average_physical"; + case "TURNOVER_ASC","TURNOVER_DESC" -> "turnover"; + case "NO_OUTBOUND_PHYSICAL_DESC" -> "no_outbound_physical"; + default -> throw new IllegalArgumentException("Unsupported turnover sort"); }; - return " ORDER BY "+field+" IS NULL,"+field+("COVERAGE_ASC".equals(p.query.sort())?" ASC":" DESC")+(owner?",customer_code":",sku_code,customer_code"); + return " ORDER BY "+field+" IS NULL,"+field+(p.query.sort().endsWith("_ASC")?" ASC":" DESC")+(owner?",customer_code":",sku_code,customer_code"); } public static String page(Map args) { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java index 376e45092..9dd6d3928 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java @@ -7,7 +7,7 @@ import java.math.RoundingMode; import java.time.LocalDate; import java.time.ZoneId; -/** Inventory-only complete natural-day window; dashboard and ranking policies remain fixed. */ +/** Inventory-only complete natural-day window; dashboard drilldowns resolve their explicit selected dates separately. */ final class InventoryWindowPolicy { private InventoryWindowPolicy() {} static SalesRangePolicy.Range range(AnalyticsInventoryOptions o,LocalDate anchor) { @@ -15,17 +15,13 @@ final class InventoryWindowPolicy { if(days!=10&&days!=30)throw new BizException("库存销量窗口仅支持10或30天"); LocalDate end=o.salesWindowEndDate()==null?anchor.minusDays(1):o.salesWindowEndDate(); if(!end.isBefore(anchor))throw new BizException("库存销量截止日必须早于查询当日"); - boolean defaultThirty=days==30&&end.equals(anchor.minusDays(1)); - if(!defaultThirty&&(o.coverageBucket()!=null||o.coverageSelection()!=null||o.drilldownFrom()!=null - ||"NO_OUTBOUND_30D".equals(o.attentionValue())||"OUTBOUND_30D_ASC".equals(o.sortValue()))) - throw new BizException("30日关注、排序及总览下钻仅适用于截至昨日的30日窗口"); LocalDate until=end.plusDays(1),from=until.minusDays(days);var zone=ZoneId.of("Asia/Shanghai"); return new SalesRangePolicy.Range(anchor,from,until,days,from.atStartOfDay(zone).toInstant(),until.atStartOfDay(zone).toInstant()); } record Result(BigDecimal average,BigDecimal days,String status) {} static Result calculate(String quality,BigDecimal available,BigDecimal sales,boolean trusted, boolean complete,boolean observed,boolean unit,int days) { - if(days!=10&&days!=30)throw new IllegalArgumentException("Inventory window must be 10 or 30 days"); + if(days<1||days>366)throw new IllegalArgumentException("Inventory window must be 1 to 366 days"); if(!"VALID".equals(quality)||available==null)return new Result(null,null,"INVENTORY_"+quality); if(available.signum()<0)return new Result(null,null,"INVENTORY_INVALID"); BigDecimal divisor=BigDecimal.valueOf(days); diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index 37711e475..2ec0723fb 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -13,7 +13,7 @@ info: 默认maxQueryDays366,粒度DAY。正式outboundQuantity与observedQuantity独立,全部未知仍为空。 每日COMPLETE仅证明全天覆盖及有效日行或可靠SKU存在;今日/部分日正式量空,观察量可用,未知日期保持空。 SKU区间正式量仅全部日完整才输出;observedQuantity为可靠点合计,完整/部分/未知日数独立。 - 热销榜跟随图表所选日期,以已采集有效出库量排序;覆盖不足标记PARTIAL,不改变30日日均分母;不触发领星请求。 + 热销榜跟随图表所选日期,以已采集有效出库量排序;覆盖不足标记PARTIAL,日均、库存分布及两库存榜使用同一所选区间的完整销量与自然日数;不触发领星请求。 运行接口不受经营context限制。动态库存时效、重建和修订待核对独立返回。 阶段进度区分ACCOUNT_SHARED/WAREHOUSE及TASK_CUMULATIVE/ATTEMPT_ONLY,不伪造总百分比。 total未知为空、计数不能跨PAGE/ORDER/INVENTORY_ROW/BUSINESS_KEY/SKU/BATCH单位求和,NONE为非计数阶段且completed/total为空、totalKnown=false;阶段检查点与心跳分开。 @@ -26,7 +26,7 @@ paths: summary: 单仓固定版本经营总览 description: >- 平台独立只读权限platform:self-operated-analytics:list。一次一致快照固定销量和库存版本。 - 默认LAST_15_DAYS同时用于趋势、区间出库和热销Top10;热销按已采集有效量排序并标记覆盖状态。日均、互斥分桶及两个库存Top10固定至昨日30完整日。 + 默认LAST_15_DAYS统一用于趋势、区间出库、日均、互斥分桶及三个Top10;热销按已采集有效量排序并标记覆盖状态,库存评估要求所选区间完整销量及观察资格。 不接受货主、SKU、分页、windowDays、topN或其他额外筛选。仓级趋势不套单SKU观察期。 库存严格下钻须携带原版本和drilldownFrom=DASHBOARD;截止时间由服务端根据库存真实观测重算。 parameters: @@ -182,9 +182,12 @@ paths: '403': {description: 非平台身份或缺少独立只读权限} /api/platform/v1/last-leg/self-operated-analytics/inventory-coverage: get: - summary: 正品库存与固定10或30日可售天数 - description: '固定10或30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' + summary: 正品库存与所选窗口可售天数 + description: '普通库存明细使用10或30天;总览严格下钻须携带drilldownFrom=DASHBOARD及原图CUSTOM起止日期,不得同时携带salesWindowDays/salesWindowEndDate。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' parameters: + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} - {$ref: '#/components/parameters/coverageBucket'} - {$ref: '#/components/parameters/bucketRuleVersion'} - {$ref: '#/components/parameters/coverageSelection'} @@ -384,13 +387,13 @@ components: name: coverageBucket in: query description: 与非ALL attention和coverageSelection互斥,须配套固定规则版本;SQL分页前采用与总览相同互斥谓词。 - schema: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_30D, NO_AVAILABLE, UNKNOWN]} + schema: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_IN_WINDOW, NO_AVAILABLE, UNKNOWN]} bucketRuleVersion: {name: bucketRuleVersion, in: query, description: 不带coverageBucket时禁止单独传入, schema: {type: string, enum: [COVERAGE_DISTRIBUTION_V1]}} coverageSelection: {name: coverageSelection, in: query, description: 正式有限正可售天数候选,在SQL分页前过滤,与非ALL attention及coverageBucket互斥, schema: {type: string, enum: [FINITE_POSITIVE]}} drilldownFrom: {name: drilldownFrom, in: query, description: 必须固定原版本,服务端在真实库存有效期与查询到期较早者阻断并要求刷新总览;不增加权限, schema: {type: string, enum: [DASHBOARD]}} keyword: {name: keyword, in: query, schema: {type: string, minLength: 1, maxLength: 64}, description: 区分大小写的货主编码字面前缀,先过滤去重再分页} - attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}} - sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_30D_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序} + attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}} + sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序} coverageDaysThreshold: {name: coverageDaysThreshold, in: query, schema: {type: integer, minimum: 1}, description: COVERAGE_GT_THRESHOLD必填,严格大于且未舍入比较,不设置默认经营政策} sourceAccount: {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} warehouseCode: {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} @@ -425,9 +428,6 @@ components: - {$ref: '#/components/schemas/Context'} - type: object properties: - fixedWindowStartDate: {type: string, format: date} - fixedWindowEndDateExclusive: {type: string, format: date} - fixedWindowDays: {type: integer, enum: [30]} inventoryFreshUntil: {type: string, format: date-time, nullable: true} inventoryDrilldownExpiresAt: {type: string, format: date-time, nullable: true} DashboardMetric: @@ -445,11 +445,11 @@ components: type: object properties: intervalOutboundQuantity: {$ref: '#/components/schemas/DashboardMetric'} - averageDailyOutboundQuantity30d: {$ref: '#/components/schemas/DashboardMetric'} + averageDailyOutboundQuantity: {$ref: '#/components/schemas/DashboardMetric'} physicalQuantity: {$ref: '#/components/schemas/DashboardMetric'} availableQuantity: {$ref: '#/components/schemas/DashboardMetric'} physicalSkuCount: {$ref: '#/components/schemas/DashboardMetric'} - noOutboundSkuCount30d: {$ref: '#/components/schemas/DashboardMetric'} + noOutboundSkuCount: {$ref: '#/components/schemas/DashboardMetric'} CoverageDistribution: type: object description: 数值桶按未舍入交叉乘积判断;七桶互斥守恒。库存版未发布时计数为空,不把销量候选伪造成库存总量。 @@ -467,7 +467,7 @@ components: items: type: object properties: - bucketCode: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_30D, NO_AVAILABLE, UNKNOWN]} + bucketCode: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_IN_WINDOW, NO_AVAILABLE, UNKNOWN]} skuCount: {type: string, nullable: true} knownPhysicalQuantity: {type: number, nullable: true} unknownPhysicalSkuCount: {type: string, nullable: true} @@ -493,7 +493,7 @@ components: properties: windowStartDate: {type: string, format: date} windowEndDateExclusive: {type: string, format: date} - calculationWindowDays: {type: integer, minimum: 1, description: 热销为所选日期天数;库存榜固定30} + calculationWindowDays: {type: integer, minimum: 1, description: 三个榜单均为所选日期区间的自然日数} eligibleCount: {type: string, nullable: true} unassessableCount: {type: string, nullable: true} items: {type: array, maxItems: 10, items: {$ref: '#/components/schemas/DashboardRankingItem'}} @@ -658,7 +658,7 @@ components: status: {type: string, nullable: true} reasonCodes: {type: array, items: {type: string}} InventoryMetric: - description: 库存独立窗口响应。windowOutboundQuantity为所选完整10或30日销量;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。30日专属筛选及总览下钻不能用于10日或历史截止窗口。 + description: 库存窗口响应。windowOutboundQuantity为所选完整窗口销量:普通明细10或30日,总览严格下钻使用原图CUSTOM区间;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。无出库关注、出库排序及分组均使用实际窗口。 type: object properties: customerName: {type: string, nullable: true} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml index 386d0c90c..01cffcabd 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -39,8 +39,8 @@ paths: - {name: turnoverStatus, in: query, schema: {type: string, enum: [INSUFFICIENT]}} - name: sortBy in: query - description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。使用正式值及未舍入值排序。' - schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_DESC, AVAILABLE_DESC, OUTBOUND_7_DESC, OUTBOUND_15_DESC, OUTBOUND_30_DESC, COVERAGE_ASC, COVERAGE_DESC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]} + description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。全量筛选后按正式未舍入值排序再分页,未知值始终末尾,同值按SKU及货主升序。AVERAGE_DAILY使用baseWindowDays指定的日均窗口。' + schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_ASC, PHYSICAL_DESC, AVAILABLE_ASC, AVAILABLE_DESC, OUTBOUND_7_ASC, OUTBOUND_7_DESC, OUTBOUND_15_ASC, OUTBOUND_15_DESC, OUTBOUND_30_ASC, OUTBOUND_30_DESC, AVERAGE_DAILY_ASC, AVERAGE_DAILY_DESC, COVERAGE_ASC, COVERAGE_DESC, FROZEN_ASC, FROZEN_DESC, DEFECTIVE_ASC, DEFECTIVE_DESC, AVERAGE_PHYSICAL_ASC, AVERAGE_PHYSICAL_DESC, TURNOVER_ASC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]} - {name: current, in: query, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} - {name: includeOverview, in: query, schema: {type: boolean, default: true}} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java index 854d15af4..59c35921b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java @@ -339,15 +339,29 @@ class AnalyticsQueryServiceTest { assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null,null,null))); verifyNoInteractions(mapper); } + @Test void dashboardInventoryUsesExactSelectedDatesInsteadOfDefaultThirtyDays(){ + var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE","DASHBOARD",null,null); + for(int days:List.of(1,7,15,45)){ + clearInvocations(mapper); + var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(days),D.minusDays(1),null,null,1,20,11L,22L,SH,IH,NOW); + var response=service.inventoryCoverage(q,o); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).inventoryPage(cap.capture(),eq(o)); + assertEquals(D.minusDays(days),cap.getValue().from());assertEquals(D,cap.getValue().until()); + assertEquals(D.minusDays(days),response.context().rangeStartDate());assertEquals(D,response.context().rangeEndDateExclusive()); + assertEquals(days,response.context().calculationWindowDays()); + assertThrows(BizException.class,()->service.inventoryCoverage(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,"DASHBOARD",30,null))); + } + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o)); + } @Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){ var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD",null,null); assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o)); when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93600),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); - var q=pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1); + var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(7),D.minusDays(1),null,null,1,20,11L,22L,SH,IH,NOW.minusSeconds(60)); assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->service.inventoryCoverage(q,o)).getMessage()); verify(mapper,never()).inventoryCount(any(),any());verify(mapper,never()).inventoryPage(any(),any()); // Normal inventory diagnostics still return the same fixed but stale version. - assertEquals("STALE",service.inventoryCoverage(q,options()).context().inventoryStatus()); + assertEquals("STALE",service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1),options()).context().inventoryStatus()); } private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);} private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java index be6937aa7..b799f412f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java @@ -256,12 +256,12 @@ public class AnalyticsSqlSemanticTest { var row=inventory(null,null,null,0,20).get(0); assertDecimal("23",row.physicalQuantity());assertDecimal("10",row.availableQuantity());assertNull(row.frozenQuantity()); assertNull(row.defectivePhysicalQuantity());assertEquals(1,row.noOutbound());assertNull(row.coverageDays()); - assertEquals(1,inventory("NO_OUTBOUND_30D",null,null,0,20).size()); + assertEquals(1,inventory("NO_OUTBOUND_IN_WINDOW",null,null,0,20).size()); } @Test void frozenDefectiveOnlyStockMatchesNoOutboundEvenWhenGoodAvailableIsZero() throws Exception { profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); stockExact("SKU",0,"0","0","0","0","VALID",null);stockExact("SKU",1,"40","0","40","0","VALID",null); - var row=inventory("NO_OUTBOUND_30D",null,null,0,20).get(0); + var row=inventory("NO_OUTBOUND_IN_WINDOW",null,null,0,20).get(0); assertDecimal("0",row.coverageDays());assertEquals(1,row.noOutbound());assertEquals(1,inventory("FROZEN_STOCK",null,null,0,20).size()); assertEquals(1,inventory("DEFECTIVE_STOCK",null,null,0,20).size());assertTrue(inventory("COVERAGE_GT_THRESHOLD",1,null,0,20).isEmpty()); } @@ -396,7 +396,7 @@ public class AnalyticsSqlSemanticTest { var bucket=new AnalyticsInventoryOptions(null,null,null,null,"DAYS_GT_90","COVERAGE_DISTRIBUTION_V1",null,null,null,null); assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",bucket)).contains("stock_paged")); assertEquals(List.of("B/A","C/A"),inventoryRows(p,bucket).stream().map(r->r.customerCode()+"/"+r.skuCode()).toList()); - for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_30D_ASC")) + for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_ASC")) assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null,null,null))).contains("stock_paged")); } @Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java index 585c2628d..ae2fff259 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java @@ -20,7 +20,8 @@ class DashboardPolicyTest { } static final LocalDate DAY=LocalDate.of(2026,10,13); static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z"); - static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,DAY.minusDays(14),DAY.plusDays(1),null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());} + static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return context(coveredFrom,coveredUntil,inventory,DAY.minusDays(14),DAY.plusDays(1));} + static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory,LocalDate from,LocalDate until){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,from,until,null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());} @Test void warehouseObservedTotalPreservesOutsideWatermarkAndUnknownGap(){ var c=context(null,null,true);var rows=List.of(new DashboardReadMapper.Day(DAY.minusDays(3),new BigDecimal("2"),0L),new DashboardReadMapper.Day(DAY.minusDays(1),new BigDecimal("5"),0L),new DashboardReadMapper.Day(DAY,BigDecimal.ONE,0L)); var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows,java.util.List.of());var summary=SalesBucketPolicy.aggregate(points); @@ -76,7 +77,7 @@ class DashboardPolicyTest { for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null), new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null), new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null), - new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null,null,null), + new AnalyticsInventoryOptions(null,"NO_OUTBOUND_IN_WINDOW",null,null,null,null,"FINITE_POSITIVE",null,null,null), new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",null,null,null)))assertThrows(BizException.class,()->o.validate("INVENTORY",query(true))); assertThrows(BizException.class,()->DashboardRequest.validateParameterNames(Set.of("sourceAccount","warehouseCode","topN"))); assertEquals("LAST_15_DAYS",DashboardRequest.normalize(query(false)).rangePreset()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java index 23a3c6784..d350178cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java @@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class DashboardQueryServiceTest { - @Test void oneContextKeepsThirtyDaySalesAndHotAvailableWhenInventoryUnavailable()throws Exception{ + @Test void selectedWindowKeepsHotAvailableButDoesNotInventAverageWhenIncomplete()throws Exception{ var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper); LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW; var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null); @@ -27,11 +27,12 @@ class DashboardQueryServiceTest { reportRow.putAll(Map.of("rank_code","HOT","customer_code","A","sku_code","X","product_name","Product","main_value",new BigDecimal("30"),"owner_outbound",new BigDecimal("30"),"order_count",1L)); when(mapper.report(any(),any())).thenReturn(List.of(reportRow)); var result=service.dashboard(q); - assertEquals(0,BigDecimal.ONE.compareTo(result.kpis().averageDailyOutboundQuantity30d().value())); + assertNull(result.kpis().averageDailyOutboundQuantity().value()); assertEquals(15,result.hotSkus().calculationWindowDays());assertEquals("PARTIAL",result.hotSkus().dataStatus());assertNull(result.hotSkus().items().get(0).averageDailyOutboundQuantity()); assertEquals(200L,result.hotSkus().eligibleCount());assertEquals(1,result.hotSkus().items().size());assertEquals("A",result.hotSkus().items().get(0).customerCode()); assertNull(result.coverageDistribution().candidateSkuCount());assertTrue(result.coverageDistribution().buckets().stream().allMatch(b->b.skuCount()==null)); - assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(30),result.context().fixedWindowStartDate()); + assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(14),result.context().versionContext().rangeStartDate()); + assertEquals(15,result.longCoverageSkus().calculationWindowDays());assertEquals(15,result.noOutboundSkus().calculationWindowDays()); verify(mapper).daily(any(),any(),any());verify(mapper,times(2)).report(any(),any());verifyNoMoreInteractions(mapper); var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext")); assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly()); @@ -40,8 +41,8 @@ class DashboardQueryServiceTest { var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper); LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW; var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null); - var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false); - var range=SalesRangePolicy.resolve("TODAY",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366); + var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false,day.minusDays(15),day); + var range=SalesRangePolicy.resolve("CUSTOM",day.minusDays(15),day.minusDays(1),"DAY",now,ZoneId.of("Asia/Shanghai"),366); for(Boolean ready:Arrays.asList(false,null)){ when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context)); var error=assertThrows(com.baiyue.common.core.exception.BizException.class,()->service.dashboard(q)); @@ -53,11 +54,11 @@ class DashboardQueryServiceTest { for(boolean salesPresent:List.of(true,false)){ var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context); when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot); - when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day, + when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day.minusDays(15),day,day,day.minusDays(30),day.minusDays(30),day, day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD",java.util.List.of())); var result=service.dashboard(q); - if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());} - else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());} + if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity().dataStatus());} + else {assertNull(result.kpis().averageDailyOutboundQuantity().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity().dataStatus());} } } private static AnalyticsReadRows.Run salesRun(Boolean ready){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index dff6165bf..38eb6f4c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -39,6 +39,40 @@ class DashboardSqlSemanticTest { assertTrue(bound.getSql().endsWith("ORDER BY business_date")); } } + @Test void selectedDatesChangeDistributionLongestAndZeroOutboundTogetherWithExactDrilldowns()throws Exception{ + fixture("RECENT","90","90","30");fixture("OLDER","60","60",null); + daily("C","OLDER",D.minusDays(20),"10","PCS"); + // 7 complete recent days: RECENT=21 days to sell, OLDER has zero outbound. + // 30 complete days: RECENT=90, OLDER=180. Historical 7 days: only OLDER sold. + for(var period:List.of(new LocalDate[]{D.minusDays(7),D},new LocalDate[]{D.minusDays(30),D},new LocalDate[]{D.minusDays(21),D.minusDays(14)})){ + int days=(int)java.time.temporal.ChronoUnit.DAYS.between(period[0],period[1]); + boolean historical=period[1].isBefore(D); + var p=windowRequest(period[0],period[1],true,"DASHBOARD"); + var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options())); + var ranks=rows.stream().map(row->{Map normalized=new HashMap<>();((Map)row).forEach((k,v)->normalized.put(k.toString().toLowerCase(Locale.ROOT),v));return DashboardQueryService.ranked(normalized);}).toList(); + var longest=ranks.stream().filter(r->"LONG".equals(r.rankCode())).max(Comparator.comparing(DashboardReadMapper.Ranked::mainValue)).orElseThrow(); + assertEquals(days==30||historical?"OLDER":"RECENT",longest.skuCode()); + assertEquals(0,new BigDecimal(days==30?"180":historical?"42":"21").compareTo(longest.mainValue())); + var zero=ranks.stream().filter(r->"NO_OUTBOUND".equals(r.rankCode())).map(DashboardReadMapper.Ranked::skuCode).toList(); + assertEquals(days==30?List.of():List.of(historical?"RECENT":"OLDER"),zero); + for(String bucket:CoverageDistributionPolicy.BUCKETS){ + var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null); + var identities=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()),false,true).stream().map(Map.class::cast) + .filter(r->bucket.equals(r.get("BUCKET_CODE"))).map(r->r.get("SKU_CODE")).collect(java.util.stream.Collectors.toSet()); + var detail=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",windowRequest(period[0],period[1],true,"INVENTORY"),"o",o)) + .stream().map(AnalyticsReadRows.Inventory.class::cast).map(AnalyticsReadRows.Inventory::skuCode).collect(java.util.stream.Collectors.toSet()); + assertEquals(identities,detail,bucket+" "+period[0]); + if("DAYS_0_30".equals(bucket))assertEquals(days==7&&!historical?Set.of("RECENT"):Set.of(),identities); + } + } + // Incomplete selection must not turn missing days into zero outbound. + var incomplete=query(DashboardReadMapper.class,"report",Map.of("p",windowRequest(D.minusDays(7),D.plusDays(1),false,"DASHBOARD"),"o",options())); + assertTrue(incomplete.stream().map(Map.class::cast).allMatch(r->r.get("RANK_CODE")==null)); + assertEquals(2L,((Number)((Map)incomplete.get(0)).get("BUCKET_UNKNOWN")).longValue()); + } + private AnalyticsSqlRequest windowRequest(LocalDate from,LocalDate until,boolean complete,String kind){var p=request(); + return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),from,until,D,from,p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,complete,true,true,0,20,kind,List.of()); + } @Test void selectedRangeHotUsesObservedSalesIncludingTodayAndMatchesTop100DespiteIncompleteCoverage()throws Exception{ fixture("RECENT","50","50","3");fixture("OLD","50","50",null); daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS"); @@ -110,7 +144,7 @@ class DashboardSqlSemanticTest { {"NINETY-ABOVE","90.000001","DAYS_GT_90"},{"LONG-A","100","DAYS_GT_90"},{"LONG-B","100","DAYS_GT_90"}}; for(var row:finite){fixture(row[0],row[1],row[1],"30");expected.get(row[2]).add("C/"+row[0]);} for(int i=0;i<3;i++){ - fixture("NOOUT-"+i,"100","100",null);expected.get("NO_OUTBOUND_30D").add("C/NOOUT-"+i); + fixture("NOOUT-"+i,"100","100",null);expected.get("NO_OUTBOUND_IN_WINDOW").add("C/NOOUT-"+i); fixture("FROZEN-"+i,"50","0",null);expected.get("NO_AVAILABLE").add("C/FROZEN-"+i); fixture("UNKNOWN-"+i,null,null,null);expected.get("UNKNOWN").add("C/UNKNOWN-"+i); } @@ -152,7 +186,7 @@ class DashboardSqlSemanticTest { } @Test void exactNinetyBoundaryAndUnknownInventoryRemainDistinct()throws Exception{ fixture("NINETY","90","90","30");fixture("NINETY04","90.04","90.04","30");fixture("INFINITE","999","999",null); - var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_30d")); + var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_in_window")); assertEquals(2,count(s,"long_eligible"));assertTrue(ranks().stream().filter(r->r.rankCode().equals("LONG")).noneMatch(r->r.skuCode().equals("INFINITE"))); } @Test void twoHundredHotCandidatesCountBeyondTopHundredAndKeepTopTenIdentical()throws Exception{ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java index f4f64651e..8164b0fd5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -63,6 +63,8 @@ class InventoryTurnoverContextTest { var c=contexts.resolve(request(null)).response(); assertEquals(0L,c.analyticsContext().salesRunId());assertEquals(0L,c.analyticsContext().inventoryRunId()); assertEquals("UNKNOWN",c.analyticsContext().inventoryStatus()); + var absent=InventoryTurnoverSql.chartInput(request(null),contexts.resolve(request(null))); + assertFalse(absent.salesKnown());assertTrue(absent.days().stream().allMatch(d->d.coverage()==0)); assertEquals(c,contexts.resolve(request(c.snapshotContext())).response()); when(analytics.scopes("demo","W")).thenReturn(List.of()); assertThrows(BizException.class,()->contexts.resolve(request(null))); @@ -81,6 +83,27 @@ class InventoryTurnoverContextTest { when(analytics.applyBarriers("demo","W")).thenReturn(1L); var rebuilding=contexts.resolve(request(null)); assertTrue(InventoryTurnoverSql.input(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==0)); + assertTrue(rebuilding.response().analyticsContext().rebuildRequired()); + assertTrue(InventoryTurnoverSql.chartInput(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==1)); + } + @Test void publishedChartRetainsEighteenCoveredDaysWhileCurrentAssessmentsWaitForRebuild() { + var published=run(11,1,SH); + var eighteen=new Run(published.id(),published.taskId(),published.scopeId(),published.sourceAccount(),published.warehouseCode(),published.runRole(), + published.scopeHash(),published.scopeSnapshotJson(),published.status(),published.coverageStatus(), + END.minusDays(17).atStartOfDay().minusHours(8),published.coveredUntilUtc(),END.minusDays(17),published.completeDayUntil(), + published.fetchStartedAtUtc(),published.fetchFinishedAtUtc(),published.publishedAtUtc(),published.calculatedFactRevision(),null,null,null,"BUSINESS",true); + when(analytics.publishedRun(11L)).thenReturn(eighteen); + when(mapper.versionAt(eq(1L),eq(SH),any())).thenReturn(eighteen); + when(analytics.applyBarriers("demo","W")).thenReturn(1L); + when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"), + Map.of("section","OVERVIEW","payload","{\"sku_count\":0}"))); + var result=new InventoryTurnoverQueryService(contexts,mapper,json).query(request(null)); + assertTrue(result.context().analyticsContext().rebuildRequired()); + verify(mapper).report(argThat(p->p.days().stream().allMatch(d->d.coverage()==0))); + verify(mapper).chartWindows(argThat(p->p.salesRun()==11L&&p.inventoryRun()==22L&&p.salesKnown() + &&p.days().stream().filter(d->d.coverage()==1).count()==18 + &&p.days().subList(0,12).stream().allMatch(d->d.coverage()==0) + &&p.days().subList(15,30).stream().allMatch(d->d.coverage()==1))); } @Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() { var service=new InventoryTurnoverQueryService(contexts,mapper,json); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index 053b3bf6f..c08b433df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -103,6 +103,23 @@ class InventoryTurnoverSqlTest { assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed")); decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count")); } + @Test void eighteenPublishedDaysGiveSevenAndFifteenDayMeansWithoutInventingThirtyDays() throws Exception { + seed("A","SKU",0); + execute("DELETE FROM ll_selfop_sku_daily_sales WHERE business_dateq.validate(END.plusDays(1))); + var p=input(q,30,30,true); + // Turnover divides sales by average stock, so its ordering reverses here. + boolean lowFirst=direction.equals("ASC")!=metric.equals("TURNOVER"); + var expected=lowFirst?List.of("L","H","U"):List.of("H","L","U"); + assertEquals(expected,reportRows(p).stream().map(r->r.get("customer_code")).toList(),group+" "+q.sort()); + assertEquals(expected,query("page",p).stream().map(r->r.get("customer_code")).toList()); + } + } + } + @Test void averageDailySortUsesSelectedWindowBeforePaginationAndDoesNotSortObservedSubtotals() throws Exception { + seed("A","RECENT",0);seed("B","OLDER",0);seed("C","UNKNOWN",0); + execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN sku_code='RECENT' THEN CASE WHEN business_date>=DATE '"+END.minusDays(6)+"' THEN 10 ELSE 0 END ELSE CASE WHEN business_dater.get("w30_outbound")==null)); + assertEquals(List.of("OLDER","RECENT","UNKNOWN"),rows.stream().map(r->r.get("sku_code")).toList()); + } + @Test void rejectsUnsupportedSortNamesAndGroupingInsteadOfInterpolatingSql() { + for(String sort:List.of("physical ASC","PHYSICAL_ASC;DROP TABLE x","AVERAGE_DAILY","UNKNOWN_DESC")) + assertThrows(RuntimeException.class,()->request("SKU","ALL",sort,null,null,1,20).validate(END.plusDays(1))); + assertThrows(RuntimeException.class,()->request("OWNER","ALL","COVERAGE_ASC",null,null,1,20).validate(END.plusDays(1))); + assertThrows(RuntimeException.class,()->request("SKU","ALL","NO_OUTBOUND_PHYSICAL_DESC",null,null,1,20).validate(END.plusDays(1))); + } private List> reportRows(InventoryTurnoverSql.Input p) throws Exception { var result=new ArrayList>();var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); for(var row:query("report",p)) if("ROW".equals(row.get("section"))) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java index a936e9016..54ac47fb0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java @@ -23,14 +23,13 @@ class InventoryWindowPolicyTest { assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY.plusDays(1)),DAY)); assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(7,null),DAY)); } - @Test void tenDayAndHistoricalWindowsCannotMasqueradeAsThirtyDayDrilldowns() { + @Test void selectedWindowPredicatesWorkForTenDayAndHistoricalWindows() { for(var o:new AnalyticsInventoryOptions[]{ - new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,null,null,10,null), - new AnalyticsInventoryOptions(null,null,"OUTBOUND_30D_ASC",null,null,null,null,null,10,null), + new AnalyticsInventoryOptions(null,"NO_OUTBOUND_IN_WINDOW",null,null,null,null,null,null,10,null), + new AnalyticsInventoryOptions(null,null,"OUTBOUND_ASC",null,null,null,null,null,10,null), new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,10,null), - new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,10,null), - new AnalyticsInventoryOptions(null,null,null,null,null,null,null,"DASHBOARD",30,DAY.minusDays(2))}) - assertThrows(BizException.class,()->InventoryWindowPolicy.range(o,DAY)); + new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,10,null)}) + assertEquals(10,InventoryWindowPolicy.range(o,DAY).dayCount()); assertThrows(BizException.class,()->options(10,null).validate("OVERVIEW",null)); } @Test void tenDayFormulaKeepsMissingAndZeroSalesDistinctAndAvoidsIntermediateRounding() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java new file mode 100644 index 000000000..ab082a463 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.scripting.defaults.DefaultParameterHandler; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.sql.*; +import java.time.LocalDateTime; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Executes the production scheduling predicates offline; only date arithmetic syntax is adapted to H2. */ +class SelfopContinuousDueSqlTest { + private Connection db; + private MappedStatement statement; + private static final LocalDateTime MIDNIGHT = LocalDateTime.of(2026,10,14,16,0); + + @BeforeEach void setup() throws Exception { + db=DriverManager.getConnection("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE","sa",""); + execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,business_root_task_id BIGINT,source_account VARCHAR,execution_purpose VARCHAR,validation_root_task_id BIGINT,status VARCHAR,request_key VARCHAR,requested_by VARCHAR,task_role VARCHAR,task_type VARCHAR,next_retry_at_utc TIMESTAMP,planned_at_utc TIMESTAMP,query_until_utc TIMESTAMP,attempt_count INT)"); + var config=new Configuration();config.addMapper(SelfopCoordinationMapper.class); + statement=config.getMappedStatement(SelfopCoordinationMapper.class.getName()+".dueContinuous"); + } + @AfterEach void close() throws Exception {db.close();} + private void execute(String sql,Object...args) throws Exception { + try(var p=db.prepareStatement(sql)){for(int n=0;n due(LocalDateTime now) throws Exception { + var params=Map.of("now",now,"limit",20,"account","SYNTH-ACCOUNT"); + var bound=statement.getBoundSql(params); + String sql=bound.getSql() + .replace("DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR)","DATEADD(HOUR,8,r.planned_at_utc)") + .replace("DATE_ADD(?,INTERVAL 8 HOUR)","DATEADD(HOUR,8,CAST(? AS TIMESTAMP))") + .replace("DATE_SUB(DATE(DATEADD(HOUR,8,CAST(? AS TIMESTAMP))),INTERVAL 8 HOUR)","DATEADD(HOUR,-8,DATE(DATEADD(HOUR,8,CAST(? AS TIMESTAMP))))"); + try(var p=db.prepareStatement(sql)){ + new DefaultParameterHandler(statement,params,bound).setParameters(p); + try(var rows=p.executeQuery()){var result=new ArrayList();while(rows.next())result.add(rows.getLong("id"));return result;} + } + } + @Test void retryAfterBeijingMidnightWaitsUntilBackoffThenRemainsDispatchable() throws Exception { + // Failure at 23:59:55 + the first 10-second backoff lands on the next Beijing day. + inventory(1,"RETRY_WAIT",1,MIDNIGHT.minusHours(16),MIDNIGHT.plusSeconds(5)); + assertEquals(List.of(),due(MIDNIGHT.minusSeconds(5))); + assertEquals(List.of(),due(MIDNIGHT)); + assertEquals(List.of(1L),due(MIDNIGHT.plusSeconds(5))); + assertEquals(List.of(1L),due(MIDNIGHT.plusDays(1))); + } + @ParameterizedTest @ValueSource(strings={"READY","RETRY_WAIT","RUNNING"}) + void startedInventoryCanResumeAfterRestartAcrossMidnight(String status) throws Exception { + inventory(1,status,1,MIDNIGHT.minusHours(16),MIDNIGHT.minusSeconds(1)); + assertEquals(List.of(1L),due(MIDNIGHT)); + } + @Test void oldUnstartedSlotsAndTerminalOrUnauthorizedWorkRemainExcluded() throws Exception { + var yesterday=MIDNIGHT.minusHours(16);var today=MIDNIGHT.plusHours(8); + inventory(1,"READY",0,yesterday,null); + inventory(2,"DEAD",3,yesterday,null); + inventory(3,"SUCCEEDED",1,yesterday,null); + inventory(4,"READY",0,today,today); + inventory(5,"RETRY_WAIT",1,yesterday,null); + execute("UPDATE ll_selfop_sync_task SET source_account='OTHER-ACCOUNT' WHERE id=5"); + inventory(6,"RETRY_WAIT",1,yesterday,null); + execute("UPDATE ll_selfop_sync_task SET execution_purpose='VALIDATION',validation_root_task_id=id WHERE id=6"); + inventory(7,"RETRY_WAIT",1,yesterday,null); + execute("UPDATE ll_selfop_sync_task SET requested_by='untrusted' WHERE id=7"); + inventory(8,"RETRY_WAIT",1,today.plusDays(1),null); + assertEquals(List.of(),due(MIDNIGHT)); + assertEquals(List.of(4L),due(today)); + } +} From e14bdb4f49385f33177d4d8e61a102d00aad048f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 14 Oct 2026 11:36:35 +0800 Subject: [PATCH 73/80] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=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 --- deploy/sql/README.md | 2 + .../V24__cargo_discard_photos.sql | 16 +++ deploy/sql/海外区域统一增量-MySQL8.sql | 18 +++ .../file/service/BusinessFileCategory.java | 1 + .../openapi/cargo-discard-photo-v1.yaml | 12 ++ .../service/CargoDiscardPhotoStorageTest.java | 25 ++++ .../config/LastlegRestClientProperties.java | 3 + .../controller/CargoDiscardController.java | 10 ++ .../MerchantCargoDiscardController.java | 10 ++ .../discard/dto/CargoDiscardResponses.java | 5 +- .../discard/entity/CargoDiscardPhoto.java | 22 +++ .../gateway/CargoDiscardPhotoFileGateway.java | 12 ++ .../HttpCargoDiscardPhotoFileGateway.java | 135 ++++++++++++++++++ .../mapper/CargoDiscardApplicationMapper.java | 3 + .../mapper/CargoDiscardPhotoMapper.java | 14 ++ .../controller/PdaCargoDiscardController.java | 21 +++ .../discard/pda/dto/PdaCargoDiscardDtos.java | 3 +- .../pda/service/PdaCargoDiscardService.java | 33 +++++ .../service/CargoDiscardPhotoService.java | 75 ++++++++++ .../discard/service/CargoDiscardService.java | 9 +- .../service/RelabelApplicationService.java | 5 + .../dto/ReturnInventoryResponses.java | 12 +- .../dto/ReturnOrderResponses.java | 8 +- .../mapper/ReturnInventoryBalanceMapper.java | 6 + .../mapper/ReturnInventoryCartonMapper.java | 2 + .../mapper/ReturnPackingTraceMapper.java | 9 ++ .../service/ReturnInventoryService.java | 77 ++++++++-- .../service/ReturnReceiptService.java | 5 +- .../src/main/resources/db/lastleg.sql | 18 +++ .../openapi/cargo-discard-photos-v1.yaml | 121 ++++++++++++++++ .../resources/openapi/return-packing-v1.yaml | 6 +- .../MerchantCargoDiscardControllerTest.java | 12 ++ .../service/PdaCargoDiscardServiceTest.java | 40 +++++- .../service/CargoDiscardPhotoServiceTest.java | 85 +++++++++++ .../service/CargoDiscardServiceTest.java | 1 + .../service/ReturnInventoryServiceTest.java | 35 ++++- .../ReturnPackingBalancePersistenceTest.java | 45 ++++++ 37 files changed, 884 insertions(+), 32 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql create mode 100644 file/src/main/resources/openapi/cargo-discard-photo-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardPhotoMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java create mode 100644 lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnPackingBalancePersistenceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index ca918e5f0..d860394dd 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -145,3 +145,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。 码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。 + +`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql new file mode 100644 index 000000000..1373e87ce --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo ( + id BIGINT NOT NULL, + customer_id VARCHAR(64) NOT NULL, + application_id BIGINT NOT NULL, + pallet_no VARCHAR(128) NOT NULL, + file_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(128) NOT NULL, + size_bytes BIGINT NOT NULL, + operator_id VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + submitted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_discard_photo_file (file_id), + KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 0d8e39888..616512ab8 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4303,3 +4303,21 @@ CREATE TABLE ll_return_sku_conversion ( KEY idx_return_sku_conversion_carton(customer_id,carton_id), CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录'; + +-- PDA cargo discard photo evidence +CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo ( + id BIGINT NOT NULL, + customer_id VARCHAR(64) NOT NULL, + application_id BIGINT NOT NULL, + pallet_no VARCHAR(128) NOT NULL, + file_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(128) NOT NULL, + size_bytes BIGINT NOT NULL, + operator_id VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + submitted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_discard_photo_file (file_id), + KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index d88862760..52f3dc3ba 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -31,6 +31,7 @@ enum BusinessFileCategory { CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_CARGO_DISCARD_PHOTO(true, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml b/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml new file mode 100644 index 000000000..f9c2f6779 --- /dev/null +++ b/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: Cargo discard photo storage ownership contract + version: 1.0.0 + description: >- + Uses the existing internal business-file upload, metadata and content endpoints, protected + by X-Internal-Service-Token. businessType is OVERSEAS_CARGO_DISCARD_PHOTO; tenantId is a + positive customer ID and businessId is the positive cargo discard application ID. + File enforces composite ownership and non-empty JPG/PNG bytes with image signature and + 10 MiB limit. Lastleg owns the pallet binding, submitted visibility and immutable confirmation. + No anonymous or public file endpoint is introduced. +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java b/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java new file mode 100644 index 000000000..91c0ebf0f --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java @@ -0,0 +1,25 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.*; + +class CargoDiscardPhotoStorageTest { + @TempDir Path root; + @Test void discardEvidenceIsCustomerOwnedAndRequiresRealImageSignature() throws Exception { + var category = BusinessFileCategory.require("OVERSEAS_CARGO_DISCARD_PHOTO"); + category.validateOwner(7); + assertThatThrownBy(() -> category.validateOwner(0)).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot(); + byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10}; + var result = storage.store(7, "11", new MockMultipartFile("file", "discard.png", "image/png", bytes), category); + assertThat(storage.read(result.objectKey())).isEqualTo(bytes); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "fake.png", "image/png", "fake".getBytes()), category)).hasMessageContaining("不是有效图片"); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG"); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "large.png", "image/png", new byte[10*1024*1024+1]), category)).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java index b647f3a9b..1efa078ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -9,6 +9,7 @@ import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.http.clients") public class LastlegRestClientProperties { private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts cargoDiscardPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); @@ -18,6 +19,8 @@ public class LastlegRestClientProperties { public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } + public ClientTimeouts getCargoDiscardPhotoFile() { return cargoDiscardPhotoFile; } + public void setCargoDiscardPhotoFile(ClientTimeouts value) { cargoDiscardPhotoFile = required(value, "cargo-discard-photo-file"); } public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java index 60d987237..e63473c3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -52,6 +52,16 @@ public class CargoDiscardController { return ApiResponse.success(service.detail(scope(user, customerId), discardNo)); } + @GetMapping("/{discardNo}/photos/files/{fileId}/download") + @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('platform:overseas-warehouse:discard:list')") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String discardNo, @PathVariable long fileId) { + var file = service.downloadPhoto(scope(user, customerId), discardNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } @PostMapping("/{discardNo}/review") public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String discardNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java index f49e899da..d69a705d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -36,6 +36,16 @@ public class MerchantCargoDiscardController { @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); } + @GetMapping("/{discardNo}/photos/files/{fileId}/download") + @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('merchant:overseas-warehouse:discard:list')") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo, @PathVariable long fileId) { + var file = service.downloadPhoto(customer(user), discardNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } @PostMapping("/{discardNo}/cancel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')") public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, 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 14fb36737..168140564 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 @@ -25,7 +25,10 @@ public final class CargoDiscardResponses { public record History(String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + public record Photo(Long fileId, String palletNo, String fileName, String contentType, Long sizeBytes, + String operatorId, LocalDateTime createdAt, LocalDateTime submittedAt) {} + public record Detail(Summary summary, BigDecimal feePerPackage, String remark, String reviewedBy, String reviewRemark, List items, - List histories) {} + List histories, List photos) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java new file mode 100644 index 000000000..3fe05d37d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_photo") +public class CargoDiscardPhoto { + @TableId private Long id; + private String customerId; + private Long applicationId; + private String palletNo; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String operatorId; + private LocalDateTime createdAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java new file mode 100644 index 000000000..b27f510ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.discard.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface CargoDiscardPhotoFileGateway { + 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); + + 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/discard/gateway/HttpCargoDiscardPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java new file mode 100644 index 000000000..c84bc2290 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java @@ -0,0 +1,135 @@ +package com.baiyue.lastleg.discard.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpCargoDiscardPhotoFileGateway implements CargoDiscardPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_CARGO_DISCARD_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getCargoDiscardPhotoFile()); + } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read cargo-discard photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Cargo discard photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Cargo discard photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no cargo-discard photo metadata"); + } + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Cargo discard photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Cargo discard photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) + || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Cargo discard photo metadata does not match the PDA task"); + } + } + + @Override + public DownloadedFile download(String customerId, long taskId, long fileId) { + validate(customerId, taskId, fileId); + ResponseEntity response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + tenantId(customerId), BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Cargo discard photo download failed"); + }).toEntity(byte[].class); + String fileName = response.getHeaders().getContentDisposition().getFilename(); + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + fileName == null ? "cargo-discard-photo" : fileName, contentType); + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Customer ID must be numeric", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata 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/discard/mapper/CargoDiscardApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java index 3b314f9bc..7c53ccf11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -19,4 +19,7 @@ public interface CargoDiscardApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_photo WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id") + List listApplication(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java index 16c3f1400..0c533cd30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java @@ -24,6 +24,27 @@ public class PdaCargoDiscardController { @Valid @RequestBody PdaCargoDiscardDtos.Scan request) { return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo())); } + @PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo, + @RequestParam("file") org.springframework.web.multipart.MultipartFile file) { + return ApiResponse.success(service.uploadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo, + user.userId() == null ? 0L : user.userId(), file)); + } + @GetMapping("/{discardNo}/pallets/{palletNo}/photos/files") + public ApiResponse> photos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo) { + return ApiResponse.success(service.photos(PdaWarehouseIdentity.require(user), discardNo, palletNo)); + } + @GetMapping("/{discardNo}/pallets/{palletNo}/photos/files/{fileId}/download") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo, @PathVariable String palletNo, @PathVariable long fileId) { + var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } @PostMapping("/{discardNo}/pallets/{palletNo}/confirm") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo, @Valid @RequestBody PdaCargoDiscardDtos.Confirm request) { 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 feb1ca451..9b8caf5d4 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 @@ -12,7 +12,8 @@ public final class PdaCargoDiscardDtos { private PdaCargoDiscardDtos() { } public record Scan(@NotBlank @Size(max = 128) String palletNo) { } public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } - public record Confirm(@NotEmpty List<@Valid ActualLine> lines) { } + public record Confirm(@NotEmpty List<@Valid ActualLine> lines, + @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds) { } public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, Integer actualPackages, String status) { } public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, 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 b496842bb..0125c003b 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 @@ -23,6 +23,7 @@ import java.util.stream.Collectors; @Service public class PdaCargoDiscardService { + @Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService; @Resource private CargoDiscardApplicationMapper applications; @Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardCartonMapper cartons; @@ -78,6 +79,8 @@ public class PdaCargoDiscardService { || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()))) throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); + photoService.confirm(application, task.palletNo(), request.photoFileIds(), + palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); // A completed pallet is an immutable result, including the final pallet after application completion. if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; if (terminal) throw conflict("废弃终态不能执行未完成托盘"); @@ -109,6 +112,36 @@ public class PdaCargoDiscardService { .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); } + @Transactional + public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo, + long operatorUserId, org.springframework.web.multipart.MultipartFile file) { + CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true); + var task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst().orElseThrow(); + if (!CargoDiscardService.APPROVED.equals(application.getStatus()) || "COMPLETED".equals(task.status())) + throw conflict("已完成或未审核的废弃托盘不能上传照片"); + return photoService.upload(application, task.palletNo(), identity.operatorKey(), operatorUserId, file); + } + + @Transactional(readOnly = true) + public List photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) { + return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false); + } + + @Transactional(readOnly = true) + public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto( + PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) { + return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false); + } + + private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) { + var warehouseId = String.valueOf(identity.overseasWarehouseId()); + var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId); + if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); + if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim()))) + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"); + return application; + } + private List palletTasks(CargoDiscardApplication application) { return items.listApplication(application.getCustomerId(), application.getId()).stream() .collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java new file mode 100644 index 000000000..39de34a6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardPhoto; +import com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway; +import com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +@Service +public class CargoDiscardPhotoService { + @Resource private CargoDiscardPhotoMapper photos; + @Resource private CargoDiscardPhotoFileGateway files; + + public List list(CargoDiscardApplication application, String palletNo, boolean submittedOnly) { + return rows(application, palletNo).stream().filter(p -> !submittedOnly || p.getSubmittedAt() != null) + .map(this::view).toList(); + } + + public CargoDiscardResponses.Photo upload(CargoDiscardApplication application, String palletNo, + String operatorId, long operatorUserId, MultipartFile file) { + if (rows(application, palletNo).size() >= 20) throw conflict("每个托盘最多上传 20 张废弃照片"); + var uploaded = files.upload(application.getCustomerId(), application.getId(), operatorUserId, file); + CargoDiscardPhoto photo = new CargoDiscardPhoto(); + photo.setCustomerId(application.getCustomerId()); photo.setApplicationId(application.getId()); + photo.setPalletNo(palletNo); photo.setFileId(uploaded.fileId()); photo.setFileName(uploaded.fileName()); + photo.setContentType(uploaded.contentType()); photo.setSizeBytes(uploaded.sizeBytes()); + photo.setOperatorId(operatorId); photo.setCreatedAt(LocalDateTime.now()); photos.insert(photo); + return view(photo); + } + + // Called under the application row lock, in the same transaction as inventory deduction. + public void confirm(CargoDiscardApplication application, String palletNo, List fileIds, boolean completed) { + if (fileIds == null || fileIds.isEmpty() || fileIds.size() > 20 + || fileIds.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(fileIds).size() != fileIds.size()) + throw conflict("每个托盘须提交 1–20 张不重复的废弃照片"); + var rows = rows(application, palletNo); + var submitted = rows.stream().filter(p -> p.getSubmittedAt() != null).map(CargoDiscardPhoto::getFileId).toList(); + if (completed) { + if (!new HashSet<>(submitted).equals(new HashSet<>(fileIds))) throw conflict("已完成托盘的废弃照片不能修改"); + return; + } + var selected = rows.stream().filter(p -> fileIds.contains(p.getFileId())).toList(); + if (selected.size() != fileIds.size()) throw conflict("照片不属于当前废弃单或托盘"); + selected.forEach(p -> files.validate(application.getCustomerId(), application.getId(), p.getFileId())); + var now = LocalDateTime.now(); + selected.forEach(p -> { p.setSubmittedAt(now); photos.updateById(p); }); + } + + public CargoDiscardPhotoFileGateway.DownloadedFile download(CargoDiscardApplication application, String palletNo, + long fileId, boolean submittedOnly) { + var photo = rows(application, palletNo).stream().filter(p -> Objects.equals(p.getFileId(), fileId)) + .filter(p -> !submittedOnly || p.getSubmittedAt() != null).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃照片不存在或尚未提交")); + return files.download(application.getCustomerId(), application.getId(), photo.getFileId()); + } + + private List rows(CargoDiscardApplication application, String palletNo) { + return photos.listApplication(application.getCustomerId(), application.getId()).stream() + .filter(p -> palletNo == null || palletNo.equals(p.getPalletNo())).toList(); + } + private CargoDiscardResponses.Photo view(CargoDiscardPhoto photo) { + return new CargoDiscardResponses.Photo(photo.getFileId(), photo.getPalletNo(), photo.getFileName(), + photo.getContentType(), photo.getSizeBytes(), photo.getOperatorId(), photo.getCreatedAt(), photo.getSubmittedAt()); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} 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 56fccc1b7..fc1ac706a 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 @@ -60,6 +60,7 @@ public class CargoDiscardService { public static final String COMPLETED = "COMPLETED"; private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED); + @Resource private CargoDiscardPhotoService photoService; @Resource private CargoDiscardApplicationMapper applications; @Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardCartonMapper discardCartons; @@ -214,7 +215,13 @@ public class CargoDiscardService { .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList(); return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(), - application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews); + application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true)); + } + + public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) { + CargoDiscardApplication application = applications.findNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + return photoService.download(application, null, fileId, true); } @Transactional 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 122601547..665db4ed9 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 @@ -434,6 +434,11 @@ public class RelabelApplicationService implements WalletResultHandler { long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum); if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L)) throw conflict("SKU " + skuItem.skuCode() + " 的换标数量超过来源箱可用数量"); + var physical = returnCartonItems.lockCarton(sourceReturnCartonId).stream() + .filter(line -> line.getSkuCode().equalsIgnoreCase(skuItem.skuCode())).findFirst().orElse(null); + if (physical != null && quantity < value(physical.getQuantity()) + && returnPackingTraces.ownership(customerId, carton.getCartonMark(), physical.getSkuCode()).size() > 1) + throw conflict("跨来源单同 SKU 部分贴标须先现场分拣确认,不能按单号猜测实物来源"); } if ("CARTON_AND_SKU".equals(operationType(item)) && (!requestedSku.equals(availableSku) || returnCartonItems.lockCarton(sourceReturnCartonId).stream() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 723cd5e31..1589e311a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -46,7 +46,8 @@ public final class ReturnInventoryResponses { public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, List items, LocalDateTime generatedAt, String printableHtml) { } - public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { + public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo, String operatorName) { + public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { this(cartonMark, skuCode, totalQuantity, label, planNo, null); } public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { this(cartonMark, skuCode, totalQuantity, label, null); } } public record PutawayResult(String cartonMark, String locationCode, String palletCode, LocalDateTime putawayAt) { } @@ -59,8 +60,13 @@ public final class ReturnInventoryResponses { public record PackingPlan(String planNo, String customerId, String overseasWarehouseId, List returnNos, String mode, String status, List boxes, String remark, LocalDateTime pushedAt, List sourceCartonMarks, List sources, - LocalDateTime createdAt, List resultCartons, LocalDateTime completedAt) { } - public record PlanSource(String cartonMark, List items) { } + LocalDateTime createdAt, List resultCartons, LocalDateTime completedAt, + List